Unverified 提交 2ff6fb13 authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

feat: block unsafe npm package installs (#3152)

## Summary - add a default-on Experiments setting to block unsafe npm packages with Socket Firewall - wrap shared add-dependency installs in sfw when available and bootstrap sfw via npm install -g sfw when it is missing - surface firewall bootstrap warnings through build-mode approvals and local-agent add_dependency flows, with tests for the new setting and install path ## Test plan - npm run fmt && npm run lint:fix && npm run ts - npm test 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3152" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 2b3a4427
I found a safe package to add for this app.
<dyad-add-dependency packages="lodash"></dyad-add-dependency>
Please review and approve the dependency addition.
I found a package to add for this app.
<dyad-add-dependency packages="axois"></dyad-add-dependency>
Please review and approve the dependency addition.
import { expect } from "@playwright/test";
import fs from "node:fs/promises";
import path from "node:path";
import {
testWithConfigSkipIfWindows,
Timeout,
type PageObject,
} from "./helpers/test_helper";
const originalNpmCache = process.env.npm_config_cache;
const originalNpmStoreDir = process.env.npm_config_store_dir;
const originalPnpmStoreDir = process.env.pnpm_config_store_dir;
const testSkipIfWindows = testWithConfigSkipIfWindows({
preLaunchHook: async ({ userDataDir }) => {
const npmCacheDir = path.join(userDataDir, "npm-cache");
const pnpmStoreDir = path.join(userDataDir, "pnpm-store");
await fs.mkdir(npmCacheDir, { recursive: true });
await fs.mkdir(pnpmStoreDir, { recursive: true });
process.env.npm_config_cache = npmCacheDir;
process.env.npm_config_store_dir = pnpmStoreDir;
process.env.pnpm_config_store_dir = pnpmStoreDir;
},
postLaunchHook: async () => {
if (originalNpmCache === undefined) {
delete process.env.npm_config_cache;
} else {
process.env.npm_config_cache = originalNpmCache;
}
if (originalNpmStoreDir === undefined) {
delete process.env.npm_config_store_dir;
} else {
process.env.npm_config_store_dir = originalNpmStoreDir;
}
if (originalPnpmStoreDir === undefined) {
delete process.env.pnpm_config_store_dir;
} else {
process.env.pnpm_config_store_dir = originalPnpmStoreDir;
}
},
});
async function openMinimalBuildChat(po: PageObject) {
await po.setUp();
await po.navigation.goToSettingsTab();
await expect(
po.page.getByRole("switch", { name: "Block unsafe npm packages" }),
).toBeChecked();
await po.navigation.goToAppsTab();
await po.importApp("minimal");
await po.chatActions.waitForChatCompletion({ timeout: Timeout.LONG });
await po.chatActions.clickNewChat();
await po.chatActions.selectChatMode("build");
const appPath = await po.appManagement.getCurrentAppPath();
return {
packageJsonPath: path.join(appPath, "package.json"),
pnpmLockPath: path.join(appPath, "pnpm-lock.yaml"),
};
}
testSkipIfWindows(
"build mode - safe npm package installs through the real socket firewall path",
async ({ po }) => {
const { packageJsonPath, pnpmLockPath } = await openMinimalBuildChat(po);
const initialPackageJson = await fs.readFile(packageJsonPath, "utf8");
const initialPnpmLock = await fs.readFile(pnpmLockPath, "utf8");
await po.sendPrompt("tc=add-safe-dependency");
await expect(po.page.getByTestId("approve-proposal-button")).toBeVisible({
timeout: Timeout.LONG,
});
await po.approveProposal();
await expect(async () => {
const packageJson = JSON.parse(
await fs.readFile(packageJsonPath, "utf8"),
);
expect(packageJson.dependencies?.lodash).toEqual(expect.any(String));
expect(await fs.readFile(pnpmLockPath, "utf8")).not.toBe(initialPnpmLock);
}).toPass({
timeout: Timeout.EXTRA_LONG,
});
await expect(
po.page.getByText(/Failed to add dependencies:/),
).not.toBeVisible();
expect(await fs.readFile(packageJsonPath, "utf8")).not.toBe(
initialPackageJson,
);
},
);
testSkipIfWindows(
"build mode - blocked unsafe npm package shows the real socket verdict and preserves app files",
async ({ po }) => {
const { packageJsonPath, pnpmLockPath } = await openMinimalBuildChat(po);
const initialPackageJson = await fs.readFile(packageJsonPath, "utf8");
const initialPnpmLock = await fs.readFile(pnpmLockPath, "utf8");
await po.sendPrompt("tc=add-unsafe-dependency");
await expect(po.page.getByTestId("approve-proposal-button")).toBeVisible({
timeout: Timeout.LONG,
});
await po.approveProposal();
const errorCard = po.page.getByRole("button", {
name: /Failed to add dependencies: axois\./i,
});
await expect(errorCard).toBeVisible({
timeout: Timeout.EXTRA_LONG,
});
await errorCard.click();
await expect(errorCard).toContainText(/blocked npm package/i, {
timeout: Timeout.MEDIUM,
});
await expect(errorCard).toContainText(/axois/i, {
timeout: Timeout.MEDIUM,
});
await expect(errorCard).toContainText(/malware/i, {
timeout: Timeout.MEDIUM,
});
expect(await fs.readFile(packageJsonPath, "utf8")).toBe(initialPackageJson);
expect(await fs.readFile(pnpmLockPath, "utf8")).toBe(initialPnpmLock);
},
);
......@@ -7,3 +7,7 @@ When adding a new toggle/setting to the Settings page:
3. Add a `SETTING_IDS` entry and search index entry in `src/lib/settingsSearchIndex.ts`
4. Create a switch component (e.g., `src/components/MySwitch.tsx`) - follow `AutoApproveSwitch.tsx` as a template
5. Import and add the switch to the relevant section in `src/pages/settings.tsx`
For settings whose default can be overridden remotely:
- Prefer leaving the raw stored field unset until the user explicitly changes it, then compute the effective value as `stored value ?? remote default ?? built-in fallback`. Do not persist remote-applied defaults into `user-settings.json`.
......@@ -111,11 +111,17 @@ If this happens:
## Common flaky test patterns and fixes
- **After `po.importApp(...)`**: Some imports trigger an initial assistant turn (for example `minimal` generating `AI_RULES.md`) that can leave a visible `Retry` button in the chat. If the test is about a later prompt, first wait for that import-time turn to finish, then start a new chat before calling `sendPrompt()`, or helper methods that wait on `Retry` visibility may return too early.
- **After `page.reload()`**: Always add `await page.waitForLoadState("domcontentloaded")` before interacting with elements. Without this, the page may not have re-rendered yet.
- **Keyboard navigation events (ArrowUp/ArrowDown)**: Add `await page.waitForTimeout(100)` between sequential keyboard presses to let the UI state settle. Rapid keypresses can cause race conditions in menu navigation.
- **Navigation to tabs**: Use `await expect(link).toBeVisible({ timeout: Timeout.EXTRA_LONG })` before clicking tab links (especially in `goToAppsTab()`). Electron sidebar links can take time to render during app initialization.
- **Confirming flakiness**: Use `PLAYWRIGHT_RETRIES=0 PLAYWRIGHT_HTML_OPEN=never npm run e2e -- e2e-tests/<spec> --repeat-each=10` to reproduce flaky tests. `PLAYWRIGHT_RETRIES=0` is critical — CI defaults to 2 retries, hiding flakiness.
## Real Socket Firewall E2E tests
- When exercising the real `sfw` binary in E2E, set fresh per-test `npm_config_cache`, `npm_config_store_dir`, and `pnpm_config_store_dir` in the launch hooks. Reused caches/stores can make Socket Firewall report that it did not detect package fetches, which turns blocked-package tests into false negatives.
- For real-path blocked-package coverage, prefer `axois` over `lodahs`. `lodahs` can resolve to `0.0.1-security` and install successfully under `pnpm`, so it does not reliably reach the blocked-package UI.
## Waiting for button state transitions
When clicking a button that triggers an async operation and changes its text/state (e.g., "Run Security Review" → "Running Security Review..."), wait for the loading state to appear and disappear rather than just waiting for the original button to be hidden:
......
......@@ -124,6 +124,10 @@ When modifying `ChatResponseChunkSchema` or adding new `safeSend("chat:response:
**Zod schema contract changes:** Making a field optional (e.g., `messages``messages.optional()`) causes TypeScript errors in all consumers that assume the field is always present. Search for all destructuring/usage sites and add guards before committing.
## End-of-turn warnings
When a main-process workflow needs to show a user-facing warning toast after a turn completes, thread it through every completion path, not just `chat:response:end`. Build-mode auto-approve and local-agent flows use `ChatResponseEndSchema`, while manual proposal approval uses `ApproveProposalResultSchema`; surface the warning in both `useStreamChat` and `ChatInput` so the behavior stays consistent.
## React + IPC integration pattern
When creating hooks/components that call IPC handlers:
......
......@@ -303,6 +303,7 @@ vi.mock("@/ipc/handlers/compaction/compaction_handler", () => ({
import { handleLocalAgentStream } from "@/pro/main/ipc/handlers/local_agent/local_agent_handler";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { buildAgentToolSet } from "@/pro/main/ipc/handlers/local_agent/tool_definitions";
// ============================================================================
// Tests
......@@ -421,6 +422,54 @@ describe("handleLocalAgentStream", () => {
});
});
describe("Warning propagation", () => {
it("includes warning messages in the error payload when a tool fails after warning", async () => {
const { event, getMessagesByChannel } = createFakeEvent();
mockSettings = buildTestSettings({ enableDyadPro: true });
mockChatData = buildTestChat();
const warningMessage = "Firewall checks were skipped for this install.";
vi.mocked(buildAgentToolSet).mockImplementationOnce((ctx) => {
return {
warn_then_fail: {
execute: async () => {
ctx.onWarningMessage?.(warningMessage);
throw new Error("Simulated tool failure");
},
},
} as any;
});
mockStreamTextImpl = (options) => ({
fullStream: (async function* () {
yield* [];
await options.tools.warn_then_fail.execute();
})(),
response: Promise.resolve({ messages: [] }),
steps: Promise.resolve([]),
});
await handleLocalAgentStream(
event,
{ chatId: 1, prompt: "test" },
new AbortController(),
{
placeholderMessageId: 10,
systemPrompt: "You are helpful",
dyadRequestId,
},
);
const errorMessages = getMessagesByChannel("chat:response:error");
expect(errorMessages).toHaveLength(1);
expect(errorMessages[0].args[0]).toMatchObject({
chatId: 1,
error: expect.stringContaining("Simulated tool failure"),
warningMessages: [warningMessage],
});
});
});
describe("Context compaction setting", () => {
it("should not run pending compaction when context compaction is disabled", async () => {
// Arrange
......@@ -1043,6 +1092,7 @@ describe("handleLocalAgentStream", () => {
if (attemptCount === 1) {
return {
fullStream: (async function* () {
yield* [];
throw {
type: "error",
sequence_number: 0,
......
......@@ -4,6 +4,8 @@ import path from "node:path";
import { safeStorage } from "electron";
import {
readSettings,
resolveEffectiveSettings,
readEffectiveSettings,
getSettingsFilePath,
encrypt,
decrypt,
......@@ -11,6 +13,7 @@ import {
import { getUserDataPath } from "@/paths/paths";
import { UserSettings } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getRemoteDesktopConfig } from "@/ipc/shared/remote_desktop_config";
// Mock dependencies
vi.mock("node:fs");
......@@ -24,11 +27,15 @@ vi.mock("electron", () => ({
vi.mock("@/paths/paths", () => ({
getUserDataPath: vi.fn(),
}));
vi.mock("@/ipc/shared/remote_desktop_config", () => ({
getRemoteDesktopConfig: vi.fn(),
}));
const mockFs = vi.mocked(fs);
const mockPath = vi.mocked(path);
const mockSafeStorage = vi.mocked(safeStorage);
const mockGetUserDataPath = vi.mocked(getUserDataPath);
const mockGetRemoteDesktopConfig = vi.mocked(getRemoteDesktopConfig);
describe("readSettings", () => {
const mockUserDataPath = "/mock/user/data";
......@@ -113,6 +120,7 @@ describe("readSettings", () => {
expect(result.telemetryConsent).toBe("opted_in");
expect(result.hasRunBefore).toBe(true);
// Should still have defaults for missing properties
expect(result.blockUnsafeNpmPackages).toBeUndefined();
expect(result.enableAutoUpdate).toBe(true);
expect(result.releaseChannel).toBe("stable");
});
......@@ -540,6 +548,45 @@ describe("readSettings", () => {
});
});
describe("effective settings", () => {
it("applies the remote default when the user has not explicitly set the setting", async () => {
mockGetRemoteDesktopConfig.mockResolvedValue({
defaults: { blockUnsafeNpmPackages: false },
});
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify({}));
const result = await readEffectiveSettings();
expect(result.blockUnsafeNpmPackages).toBe(false);
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
});
it("does not override an explicitly stored local value", () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify({}));
const result = resolveEffectiveSettings(
{
...readSettings(),
blockUnsafeNpmPackages: true,
},
null,
);
expect(result.blockUnsafeNpmPackages).toBe(true);
});
it("falls back to the built-in default when remote config is missing", () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.readFileSync.mockReturnValue(JSON.stringify({}));
const result = resolveEffectiveSettings(readSettings(), null);
expect(result.blockUnsafeNpmPackages).toBe(true);
});
});
describe("getSettingsFilePath", () => {
it("should return correct settings file path", () => {
const result = getSettingsFilePath();
......
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useSettings } from "@/hooks/useSettings";
export function BlockUnsafeNpmPackagesSwitch() {
const { settings, updateSettings } = useSettings();
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Switch
id="block-unsafe-npm-packages"
aria-label="Block unsafe npm packages"
checked={settings?.blockUnsafeNpmPackages ?? true}
onCheckedChange={(checked) => {
updateSettings({
blockUnsafeNpmPackages: checked,
});
}}
/>
<Label htmlFor="block-unsafe-npm-packages">
Block unsafe npm packages
</Label>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Uses socket.dev to detect unsafe packages and blocks them
</div>
</div>
);
}
......@@ -58,7 +58,7 @@ import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList";
import { DragDropOverlay } from "./DragDropOverlay";
import { FileAttachmentTypeDialog } from "./FileAttachmentTypeDialog";
import { showExtraFilesToast, showInfo } from "@/lib/toast";
import { showExtraFilesToast, showInfo, showWarning } from "@/lib/toast";
import { useSummarizeInNewChat } from "./SummarizeInNewChatButton";
import { ChatInputControls } from "../ChatInputControls";
import { ChatErrorBox } from "./ChatErrorBox";
......@@ -621,6 +621,12 @@ export function ChatInput({ chatId }: { chatId?: number }) {
posthog,
});
}
for (const warningMessage of result.warningMessages ?? []) {
showWarning(warningMessage);
}
if (!result.success) {
setError(result.error ?? "An error occurred while approving");
}
} catch (err) {
console.error("Error approving proposal:", err);
setError((err as Error)?.message || "An error occurred while approving");
......
......@@ -23,7 +23,7 @@ import { useChats } from "./useChats";
import { useLoadApp } from "./useLoadApp";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useVersions } from "./useVersions";
import { showExtraFilesToast } from "@/lib/toast";
import { showExtraFilesToast, showWarning } from "@/lib/toast";
import { useSearch } from "@tanstack/react-router";
import { useRunApp } from "./useRunApp";
import { useCountTokens } from "./useCountTokens";
......@@ -290,6 +290,9 @@ export function useStreamChat({
posthog,
});
}
for (const warningMessage of response.warningMessages ?? []) {
showWarning(warningMessage);
}
// Use queryClient directly with the chatId parameter to avoid stale closure issues
queryClient.invalidateQueries({ queryKey: ["proposal", chatId] });
......@@ -316,10 +319,13 @@ export function useStreamChat({
invalidateTokenCount();
onSettled?.({ success: true });
},
onError: ({ error: errorMessage }) => {
onError: ({ error: errorMessage, warningMessages }) => {
// Remove from pending set now that stream ended with error
pendingStreamChatIds.delete(chatId);
for (const warningMessage of warningMessages ?? []) {
showWarning(warningMessage);
}
console.error(`[CHAT] Stream error for ${chatId}:`, errorMessage);
setErrorById((prev) => {
const next = new Map(prev);
......
......@@ -1716,6 +1716,7 @@ ${problemReport.problems
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error: `Sorry, there was an error applying the AI's changes: ${status.error}`,
warningMessages: status.warningMessages,
});
}
......@@ -1725,6 +1726,7 @@ ${problemReport.problems
updatedFiles: status.updatedFiles ?? false,
extraFiles: status.extraFiles,
extraFilesError: status.extraFilesError,
warningMessages: status.warningMessages,
chatSummary,
} satisfies ChatResponseEnd);
} else {
......
......@@ -57,7 +57,7 @@ export function registerDependencyHandlers() {
);
}
executeAddDependency({
await executeAddDependency({
packages,
message,
appPath: getDyadAppPath(app.path),
......
......@@ -373,15 +373,20 @@ const approveProposalHandler = async (
);
if (processResult.error) {
throw new Error(
`Error processing actions for message ${messageId}: ${processResult.error}`,
);
return {
success: false,
error: `Error processing actions for message ${messageId}: ${processResult.error}`,
extraFiles: processResult.extraFiles,
extraFilesError: processResult.extraFilesError,
warningMessages: processResult.warningMessages,
};
}
return {
success: true,
extraFiles: processResult.extraFiles,
extraFilesError: processResult.extraFilesError,
warningMessages: processResult.warningMessages,
};
};
......
import { createTypedHandler } from "./base";
import { settingsContracts } from "../types/settings";
import { writeSettings, readSettings } from "../../main/settings";
import { writeSettings, readEffectiveSettings } from "../../main/settings";
export function registerSettingsHandlers() {
// Note: Settings handlers intentionally use createTypedHandler without logging
// to avoid logging sensitive data (API keys, tokens, etc.) from args/return values.
createTypedHandler(settingsContracts.getUserSettings, async () => {
const settings = readSettings();
return settings;
return readEffectiveSettings();
});
createTypedHandler(settingsContracts.setUserSettings, async (_, settings) => {
writeSettings(settings);
return readSettings();
return readEffectiveSettings();
});
}
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CommandExecutionError,
SOCKET_FIREWALL_WARNING_MESSAGE,
} from "@/ipc/utils/socket_firewall";
import {
executeAddDependency,
ExecuteAddDependencyError,
} from "./executeAddDependency";
const {
detectPreferredPackageManagerMock,
ensureSocketFirewallInstalledMock,
runCommandMock,
readEffectiveSettingsMock,
dbUpdateSetMock,
dbUpdateWhereMock,
} = vi.hoisted(() => ({
detectPreferredPackageManagerMock: vi.fn(),
ensureSocketFirewallInstalledMock: vi.fn(),
runCommandMock: vi.fn(),
readEffectiveSettingsMock: vi.fn(),
dbUpdateSetMock: vi.fn(),
dbUpdateWhereMock: vi.fn(),
}));
vi.mock("../../db", () => ({
db: {
update: vi.fn(() => ({
set: dbUpdateSetMock,
})),
},
}));
vi.mock("../../db/schema", () => ({
messages: {},
}));
vi.mock("@/main/settings", () => ({
readEffectiveSettings: readEffectiveSettingsMock,
}));
vi.mock("@/ipc/utils/socket_firewall", async () => {
const actual = await vi.importActual<
typeof import("@/ipc/utils/socket_firewall")
>("@/ipc/utils/socket_firewall");
return {
...actual,
detectPreferredPackageManager: detectPreferredPackageManagerMock,
ensureSocketFirewallInstalled: ensureSocketFirewallInstalledMock,
runCommand: runCommandMock,
};
});
describe("executeAddDependency", () => {
beforeEach(() => {
vi.clearAllMocks();
dbUpdateSetMock.mockReturnValue({
where: dbUpdateWhereMock,
});
dbUpdateWhereMock.mockResolvedValue(undefined);
detectPreferredPackageManagerMock.mockResolvedValue("pnpm");
readEffectiveSettingsMock.mockResolvedValue({
blockUnsafeNpmPackages: true,
});
});
it("preserves the firewall warning when package installation later fails", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: false,
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
});
runCommandMock.mockRejectedValueOnce(new Error("pnpm failed"));
let caughtError: unknown;
try {
await executeAddDependency({
packages: ["react"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
});
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(ExecuteAddDependencyError);
expect(caughtError).toMatchObject({
warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE],
message: "pnpm failed",
});
});
it("includes socket stderr verdict details when sfw blocks a dependency", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: true,
});
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "pnpm blocked",
stderr: "Socket Firewall blocked react\nPolicy: malware",
exitCode: 1,
}),
);
let caughtError: unknown;
try {
await executeAddDependency({
packages: ["react"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
});
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(ExecuteAddDependencyError);
expect(caughtError).toMatchObject({
displaySummary: "Socket Firewall blocked react",
displayDetails: "Socket Firewall blocked react\nPolicy: malware",
warningMessages: [],
});
});
it("does not fall back to a direct install when the real sfw cli blocks a dependency", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: true,
});
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "pnpm blocked",
stderr:
" - blocked npm package: name: axois; version: 0.0.1-security; reason: malware (critical)",
exitCode: 1,
}),
);
await expect(
executeAddDependency({
packages: ["axois"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="axois"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
}),
).rejects.toMatchObject({
displaySummary:
"- blocked npm package: name: axois; version: 0.0.1-security; reason: malware (critical)",
warningMessages: [],
});
expect(runCommandMock).toHaveBeenCalledTimes(1);
});
it("fails closed after sfw runtime failures", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: true,
});
runCommandMock.mockRejectedValueOnce(
new CommandExecutionError({
message: "sfw pnpm failed",
stderr: "Socket Firewall timed out",
exitCode: 1,
}),
);
await expect(
executeAddDependency({
packages: ["react"],
message: {
id: 1,
content:
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
}),
).rejects.toMatchObject({
displaySummary: "Socket Firewall timed out",
warningMessages: [],
});
expect(runCommandMock).toHaveBeenCalledTimes(1);
});
it("uses npm directly when pnpm is unavailable", async () => {
detectPreferredPackageManagerMock.mockResolvedValue("npm");
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: false,
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
});
runCommandMock.mockResolvedValueOnce({
stdout: "installed via npm",
stderr: "",
});
const result = await executeAddDependency({
packages: ["react"],
message: {
id: 1,
content: '<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
});
expect(runCommandMock).toHaveBeenCalledWith(
"npm",
["install", "--legacy-peer-deps", "react"],
{ cwd: "/tmp/app" },
);
expect(runCommandMock).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
installResults: "installed via npm",
warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE],
});
});
it("escapes package attributes and install output before storing the tag", async () => {
ensureSocketFirewallInstalledMock.mockResolvedValue({
available: false,
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
});
runCommandMock.mockResolvedValueOnce({
stdout: "installed <react>",
stderr: "",
});
await executeAddDependency({
packages: ['react"&<safe>'],
message: {
id: 1,
content:
'<dyad-add-dependency packages="react&quot;&amp;&lt;safe&gt;"></dyad-add-dependency>',
} as any,
appPath: "/tmp/app",
});
expect(dbUpdateSetMock).toHaveBeenCalledWith({
content:
'<dyad-add-dependency packages="react&quot;&amp;&lt;safe&gt;">installed &lt;react&gt;</dyad-add-dependency>',
});
});
});
......@@ -2,10 +2,90 @@ import { db } from "../../db";
import { messages } from "../../db/schema";
import { eq } from "drizzle-orm";
import { Message } from "@/ipc/types";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { readEffectiveSettings } from "@/main/settings";
import {
buildAddDependencyCommand,
detectPreferredPackageManager,
ensureSocketFirewallInstalled,
getCommandExecutionDisplayDetails,
runCommand,
} from "@/ipc/utils/socket_firewall";
import { escapeXmlAttr, escapeXmlContent } from "../../../shared/xmlEscape";
export const execPromise = promisify(exec);
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildPackagesAttrPattern(packages: string[]): string {
const rawPackages = packages.join(" ");
const escapedPackages = escapeXmlAttr(rawPackages);
const packageVariants = new Set([rawPackages, escapedPackages]);
return Array.from(packageVariants).map(escapeRegExp).join("|");
}
export interface ExecuteAddDependencyResult {
installResults: string;
warningMessages: string[];
}
function getFirstNonEmptyLine(value: string): string | undefined {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
}
export class ExecuteAddDependencyError extends Error {
warningMessages: string[];
originalError: unknown;
displayDetails: string;
displaySummary: string;
constructor({
error,
warningMessages,
}: {
error: unknown;
warningMessages: string[];
}) {
const message = error instanceof Error ? error.message : String(error);
const displayDetails = getCommandExecutionDisplayDetails(error) ?? message;
super(message);
this.name = "ExecuteAddDependencyError";
this.warningMessages = warningMessages;
this.originalError = error;
this.displayDetails = displayDetails;
this.displaySummary = getFirstNonEmptyLine(displayDetails) ?? message;
}
}
async function runAddDependencyCommand(
command: { command: string; args: string[] },
appPath: string,
): Promise<{
succeeded: boolean;
installResults: string;
lastError: unknown;
}> {
try {
const { stdout, stderr } = await runCommand(command.command, command.args, {
cwd: appPath,
});
return {
succeeded: true,
installResults: stdout + (stderr ? `\n${stderr}` : ""),
lastError: null,
};
} catch (error) {
return {
succeeded: false,
installResults: "",
lastError: error,
};
}
}
export async function executeAddDependency({
packages,
......@@ -15,24 +95,42 @@ export async function executeAddDependency({
packages: string[];
message: Message;
appPath: string;
}) {
const packageStr = packages.join(" ");
}): Promise<ExecuteAddDependencyResult> {
const settings = await readEffectiveSettings();
const warningMessages: string[] = [];
const { stdout, stderr } = await execPromise(
`(pnpm add ${packageStr}) || (npm install --legacy-peer-deps ${packageStr})`,
{
cwd: appPath,
},
let useSocketFirewall = settings.blockUnsafeNpmPackages !== false;
if (useSocketFirewall) {
const socketFirewall = await ensureSocketFirewallInstalled();
if (!socketFirewall.available) {
useSocketFirewall = false;
if (socketFirewall.warningMessage) {
warningMessages.push(socketFirewall.warningMessage);
}
}
}
const packageManager = await detectPreferredPackageManager();
let { succeeded, installResults, lastError } = await runAddDependencyCommand(
buildAddDependencyCommand(packages, packageManager, useSocketFirewall),
appPath,
);
const installResults = stdout + (stderr ? `\n${stderr}` : "");
if (!succeeded && lastError) {
throw new ExecuteAddDependencyError({
error: lastError,
warningMessages,
});
}
// Update the message content with the installation results
const escapedPackages = escapeXmlAttr(packages.join(" "));
const updatedContent = message.content.replace(
new RegExp(
`<dyad-add-dependency packages="${packages.join(" ")}">[^<]*</dyad-add-dependency>`,
`<dyad-add-dependency packages="(?:${buildPackagesAttrPattern(packages)})">[\\s\\S]*?</dyad-add-dependency>`,
"g",
),
`<dyad-add-dependency packages="${packages.join(" ")}">${installResults}</dyad-add-dependency>`,
`<dyad-add-dependency packages="${escapedPackages}">${escapeXmlContent(installResults)}</dyad-add-dependency>`,
);
// Save the updated message back to the database
......@@ -40,4 +138,9 @@ export async function executeAddDependency({
.update(messages)
.set({ content: updatedContent })
.where(eq(messages.id, message.id));
return {
installResults,
warningMessages,
};
}
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CommandExecutionError,
SOCKET_FIREWALL_WARNING_MESSAGE,
} from "@/ipc/utils/socket_firewall";
import { ExecuteAddDependencyError } from "./executeAddDependency";
const { executeAddDependencyMock, readSettingsMock } = vi.hoisted(() => ({
executeAddDependencyMock: vi.fn(),
readSettingsMock: vi.fn(),
}));
const dbUpdates: Array<Record<string, unknown>> = [];
vi.mock("node:fs", async () => ({
default: {
existsSync: vi.fn().mockReturnValue(false),
promises: {
readFile: vi.fn().mockResolvedValue(""),
},
},
}));
vi.mock("../../db", () => ({
db: {
query: {
chats: {
findFirst: vi.fn(),
},
messages: {
findFirst: vi.fn(),
},
},
update: vi.fn(() => ({
set: vi.fn((data: Record<string, unknown>) => {
dbUpdates.push(data);
return {
where: vi.fn().mockResolvedValue(undefined),
};
}),
})),
},
}));
vi.mock("../../paths/paths", () => ({
getDyadAppPath: vi.fn((appPath: string) => `/mock/apps/${appPath}`),
}));
vi.mock("../utils/git_utils", () => ({
gitAdd: vi.fn(),
gitCommit: vi.fn(),
gitRemove: vi.fn(),
gitAddAll: vi.fn(),
getGitUncommittedFiles: vi.fn().mockResolvedValue([]),
hasStagedChanges: vi.fn().mockResolvedValue(false),
}));
vi.mock("@/main/settings", () => ({
readSettings: readSettingsMock,
}));
vi.mock("./executeAddDependency", async () => {
const actual = await vi.importActual<typeof import("./executeAddDependency")>(
"./executeAddDependency",
);
return {
...actual,
executeAddDependency: executeAddDependencyMock,
};
});
import { db } from "../../db";
import { gitAdd } from "../utils/git_utils";
import { processFullResponseActions } from "./response_processor";
describe("processFullResponseActions add dependency errors", () => {
beforeEach(() => {
vi.clearAllMocks();
dbUpdates.length = 0;
readSettingsMock.mockReturnValue({
enableSupabaseWriteSqlMigration: false,
});
vi.mocked(db.query.chats.findFirst).mockResolvedValue({
id: 1,
appId: 1,
app: {
id: 1,
path: "test-app",
},
} as any);
vi.mocked(db.query.messages.findFirst).mockResolvedValue({
id: 1,
content: '<dyad-add-dependency packages="react"></dyad-add-dependency>',
} as any);
});
it("stores the socket stderr verdict in the appended error card", async () => {
executeAddDependencyMock.mockRejectedValue(
new ExecuteAddDependencyError({
error: new CommandExecutionError({
message:
"Command 'sfw npm install --legacy-peer-deps react' exited with code 1",
stderr:
"Socket Firewall blocked react<malware>\nPolicy: malware package",
exitCode: 1,
}),
warningMessages: [],
}),
);
await processFullResponseActions(
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
1,
{
chatSummary: undefined,
messageId: 1,
},
);
const contentUpdate = dbUpdates.find(
(update) => typeof update.content === "string",
);
expect(contentUpdate?.content).toContain(
'message="Failed to add dependencies: react. Socket Firewall blocked react&lt;malware&gt;"',
);
expect(contentUpdate?.content).toContain(
"Socket Firewall blocked react&lt;malware&gt;\nPolicy: malware package",
);
});
it("preserves warning messages when a later processing step throws", async () => {
executeAddDependencyMock.mockResolvedValue({
installResults: "installed",
warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE],
});
vi.mocked(gitAdd).mockRejectedValueOnce(new Error("git add failed"));
const result = await processFullResponseActions(
'<dyad-add-dependency packages="react"></dyad-add-dependency>',
1,
{
chatSummary: undefined,
messageId: 1,
},
);
expect(result).toMatchObject({
error: "Error: git add failed",
warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE],
});
});
});
......@@ -7,7 +7,10 @@ import path from "node:path";
import { safeJoin } from "../utils/path_utils";
import log from "electron-log";
import { executeAddDependency } from "./executeAddDependency";
import {
executeAddDependency,
ExecuteAddDependencyError,
} from "./executeAddDependency";
import {
deleteSupabaseFunction,
deploySupabaseFunction,
......@@ -43,6 +46,7 @@ import {
import { applySearchReplace } from "../../pro/main/ipc/processors/search_replace_processor";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { executeCopyFile } from "../utils/copy_file_utils";
import { escapeXmlAttr, escapeXmlContent } from "../../../shared/xmlEscape";
const readFile = fs.promises.readFile;
const logger = log.scope("response_processor");
......@@ -51,6 +55,14 @@ interface Output {
error: unknown;
}
function formatOutputError(error: unknown): string {
if (error instanceof ExecuteAddDependencyError) {
return error.displayDetails;
}
return error instanceof Error ? error.toString() : String(error);
}
export async function dryRunSearchReplace({
fullResponse,
appPath,
......@@ -110,6 +122,7 @@ export async function processFullResponseActions(
error?: string;
extraFiles?: string[];
extraFilesError?: string;
warningMessages?: string[];
}> {
logger.log("processFullResponseActions for chatId", chatId);
// Get the app associated with the chat
......@@ -153,6 +166,7 @@ export async function processFullResponseActions(
const warnings: Output[] = [];
const errors: Output[] = [];
const warningMessages: string[] = [];
try {
// Extract all tags
......@@ -216,17 +230,26 @@ export async function processFullResponseActions(
// TODO: Handle add dependency tags
if (dyadAddDependencyPackages.length > 0) {
try {
await executeAddDependency({
const addDependencyResult = await executeAddDependency({
packages: dyadAddDependencyPackages,
message: message,
appPath,
});
warningMessages.push(...addDependencyResult.warningMessages);
} catch (error) {
if (error instanceof ExecuteAddDependencyError) {
warningMessages.push(...error.warningMessages);
errors.push({
message: `Failed to add dependencies: ${dyadAddDependencyPackages.join(", ")}. ${error.displaySummary}`,
error: error.displayDetails,
});
} else {
errors.push({
message: `Failed to add dependencies: ${dyadAddDependencyPackages.join(", ")}`,
error: error,
});
}
}
writtenFiles.push("package.json");
const pnpmFilename = "pnpm-lock.yaml";
if (fs.existsSync(safeJoin(appPath, pnpmFilename))) {
......@@ -628,22 +651,28 @@ export async function processFullResponseActions(
updatedFiles: hasChanges,
extraFiles: uncommittedFiles.length > 0 ? uncommittedFiles : undefined,
extraFilesError,
warningMessages:
warningMessages.length > 0 ? [...new Set(warningMessages)] : undefined,
};
} catch (error: unknown) {
logger.error("Error processing files:", error);
return { error: (error as any).toString() };
return {
error: (error as any).toString(),
warningMessages:
warningMessages.length > 0 ? [...new Set(warningMessages)] : undefined,
};
} finally {
const appendedContent = `
${warnings
.map(
(warning) =>
`<dyad-output type="warning" message="${warning.message}">${warning.error}</dyad-output>`,
`<dyad-output type="warning" message="${escapeXmlAttr(warning.message)}">${escapeXmlContent(formatOutputError(warning.error))}</dyad-output>`,
)
.join("\n")}
${errors
.map(
(error) =>
`<dyad-output type="error" message="${error.message}">${error.error}</dyad-output>`,
`<dyad-output type="error" message="${escapeXmlAttr(error.message)}">${escapeXmlContent(formatOutputError(error.error))}</dyad-output>`,
)
.join("\n")}
`;
......
import log from "electron-log";
import { z } from "zod";
const logger = log.scope("remote_desktop_config");
const REMOTE_DESKTOP_CONFIG_TIMEOUT_MS = 5_000;
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
const FAILURE_CACHE_TTL_MS = 30 * 1000;
const RemoteDesktopConfigSchema = z.object({
version: z.string().optional(),
expiresAt: z.string().datetime().optional(),
defaults: z
.object({
blockUnsafeNpmPackages: z.boolean().optional(),
})
.optional(),
});
export type RemoteDesktopConfig = z.infer<typeof RemoteDesktopConfigSchema>;
type RemoteDesktopConfigCacheEntry = {
config: RemoteDesktopConfig | null;
expiresAt: number;
};
let remoteDesktopConfigCache: RemoteDesktopConfigCacheEntry | null = null;
let remoteDesktopConfigFetchPromise: Promise<RemoteDesktopConfig | null> | null =
null;
function getRemoteDesktopConfigUrl() {
if (process.env.DYAD_DESKTOP_CONFIG_URL) {
return process.env.DYAD_DESKTOP_CONFIG_URL;
}
return "https://api.dyad.sh/v1/desktop-config";
}
async function fetchRemoteDesktopConfig(): Promise<RemoteDesktopConfig | null> {
const response = await fetch(getRemoteDesktopConfigUrl(), {
signal: AbortSignal.timeout(REMOTE_DESKTOP_CONFIG_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(
`Desktop config request failed with status ${response.status}`,
);
}
const json = await response.json();
return RemoteDesktopConfigSchema.parse(json);
}
export async function getRemoteDesktopConfig(): Promise<RemoteDesktopConfig | null> {
if (
remoteDesktopConfigCache &&
remoteDesktopConfigCache.expiresAt > Date.now()
) {
return remoteDesktopConfigCache.config;
}
if (!remoteDesktopConfigFetchPromise) {
remoteDesktopConfigFetchPromise = (async () => {
try {
const config = await fetchRemoteDesktopConfig();
remoteDesktopConfigCache = {
config,
expiresAt: config?.expiresAt
? Date.parse(config.expiresAt)
: Date.now() + DEFAULT_CACHE_TTL_MS,
};
return config;
} catch (error) {
logger.warn("Failed to fetch remote desktop config", error);
remoteDesktopConfigCache = {
config: null,
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS,
};
return null;
} finally {
remoteDesktopConfigFetchPromise = null;
}
})();
}
return remoteDesktopConfigFetchPromise;
}
......@@ -114,6 +114,7 @@ export const ChatResponseEndSchema = z.object({
updatedFiles: z.boolean(),
extraFiles: z.array(z.string()).optional(),
extraFilesError: z.string().optional(),
warningMessages: z.array(z.string()).optional(),
totalTokens: z.number().optional(),
contextWindow: z.number().optional(),
chatSummary: z.string().optional(),
......@@ -129,6 +130,7 @@ export type ChatResponseEnd = z.infer<typeof ChatResponseEndSchema>;
export const ChatResponseErrorSchema = z.object({
chatId: z.number(),
error: z.string(),
warningMessages: z.array(z.string()).optional(),
});
/**
......
......@@ -100,6 +100,7 @@ export const ApproveProposalResultSchema = z.object({
error: z.string().optional(),
extraFiles: z.array(z.string()).optional(),
extraFilesError: z.string().optional(),
warningMessages: z.array(z.string()).optional(),
});
export type ApproveProposalResult = z.infer<typeof ApproveProposalResultSchema>;
......
import { describe, expect, it, vi } from "vitest";
import {
buildAddDependencyCommand,
detectPreferredPackageManager,
ensureSocketFirewallInstalled,
SOCKET_FIREWALL_WARNING_MESSAGE,
shouldUseCommandShell,
type CommandRunner,
type PackageManager,
} from "./socket_firewall";
describe("detectPreferredPackageManager", () => {
it("prefers pnpm when available", async () => {
const runner = vi
.fn<CommandRunner>()
.mockResolvedValue({ stdout: "10.0.0", stderr: "" });
await expect(detectPreferredPackageManager(runner)).resolves.toBe("pnpm");
expect(runner).toHaveBeenCalledWith("pnpm", ["--version"]);
});
it("falls back to npm when pnpm is unavailable", async () => {
const runner = vi
.fn<CommandRunner>()
.mockRejectedValue(new Error("ENOENT"));
await expect(detectPreferredPackageManager(runner)).resolves.toBe("npm");
expect(runner).toHaveBeenCalledWith("pnpm", ["--version"]);
});
});
describe("buildAddDependencyCommand", () => {
it.each<[PackageManager, boolean, { command: string; args: string[] }]>([
["pnpm", true, { command: "sfw", args: ["pnpm", "add", "react", "zod"] }],
[
"npm",
true,
{
command: "sfw",
args: ["npm", "install", "--legacy-peer-deps", "react", "zod"],
},
],
["pnpm", false, { command: "pnpm", args: ["add", "react", "zod"] }],
[
"npm",
false,
{
command: "npm",
args: ["install", "--legacy-peer-deps", "react", "zod"],
},
],
])(
"builds the right command for %s with sfw=%s",
(manager, useSfw, expected) => {
expect(
buildAddDependencyCommand(["react", "zod"], manager, useSfw),
).toEqual(expected);
},
);
});
describe("ensureSocketFirewallInstalled", () => {
it("returns available when sfw is already installed", async () => {
const runner = vi
.fn<CommandRunner>()
.mockResolvedValue({ stdout: "", stderr: "" });
await expect(ensureSocketFirewallInstalled(runner)).resolves.toEqual({
available: true,
});
expect(runner).toHaveBeenCalledTimes(1);
expect(runner).toHaveBeenCalledWith("sfw", ["--help"]);
});
it("installs sfw when missing and returns available", async () => {
const runner = vi
.fn<CommandRunner>()
.mockRejectedValueOnce(new Error("sfw missing"))
.mockResolvedValueOnce({ stdout: "installed", stderr: "" })
.mockResolvedValueOnce({ stdout: "", stderr: "" });
await expect(ensureSocketFirewallInstalled(runner)).resolves.toEqual({
available: true,
});
expect(runner).toHaveBeenNthCalledWith(1, "sfw", ["--help"]);
expect(runner).toHaveBeenNthCalledWith(2, "npm", ["install", "-g", "sfw"]);
expect(runner).toHaveBeenNthCalledWith(3, "sfw", ["--help"]);
});
it("returns a warning when sfw cannot be installed", async () => {
const runner = vi
.fn<CommandRunner>()
.mockRejectedValueOnce(new Error("sfw missing"))
.mockRejectedValueOnce(new Error("npm install failed"));
await expect(ensureSocketFirewallInstalled(runner)).resolves.toEqual({
available: false,
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
});
});
});
describe("shouldUseCommandShell", () => {
it("uses a shell on Windows so npm-style .cmd shims can execute", () => {
expect(shouldUseCommandShell("win32")).toBe(true);
});
it("avoids the shell on Unix platforms", () => {
expect(shouldUseCommandShell("darwin")).toBe(false);
expect(shouldUseCommandShell("linux")).toBe(false);
});
});
import { spawn } from "node:child_process";
export const SOCKET_FIREWALL_WARNING_MESSAGE =
"the npm firewall could not be installed. Warning: can not check if npm packages are safe";
export interface CommandExecutionOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
}
export interface CommandExecutionResult {
stdout: string;
stderr: string;
}
export class CommandExecutionError extends Error {
stdout: string;
stderr: string;
exitCode: number | null;
constructor({
message,
stdout = "",
stderr = "",
exitCode = null,
}: {
message: string;
stdout?: string;
stderr?: string;
exitCode?: number | null;
}) {
super(message);
this.name = "CommandExecutionError";
this.stdout = stdout;
this.stderr = stderr;
this.exitCode = exitCode;
}
}
export type CommandRunner = (
command: string,
args: string[],
options?: CommandExecutionOptions,
) => Promise<CommandExecutionResult>;
export type PackageManager = "pnpm" | "npm";
export function shouldUseCommandShell(
platform: NodeJS.Platform = process.platform,
): boolean {
return platform === "win32";
}
export function resolveExecutableName(command: string): string {
if (process.platform === "win32" && !command.includes(".")) {
return `${command}.cmd`;
}
return command;
}
export async function runCommand(
command: string,
args: string[],
options: CommandExecutionOptions = {},
): Promise<CommandExecutionResult> {
return new Promise((resolve, reject) => {
const child = spawn(resolveExecutableName(command), args, {
cwd: options.cwd,
env: options.env,
shell: shouldUseCommandShell(),
stdio: "pipe",
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
reject(
new CommandExecutionError({
message: `Failed to run command '${command} ${args.join(" ")}': ${error.message}`,
stdout,
stderr,
}),
);
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(
new CommandExecutionError({
message: `Command '${command} ${args.join(" ")}' exited with code ${code ?? "unknown"}`,
stdout,
stderr,
exitCode: code,
}),
);
});
});
}
export function getCommandExecutionDisplayDetails(
error: unknown,
): string | undefined {
if (!(error instanceof CommandExecutionError)) {
return undefined;
}
const stderr = error.stderr.trim();
if (stderr) {
return stderr;
}
const stdout = error.stdout.trim();
if (stdout) {
return stdout;
}
return undefined;
}
export async function ensureSocketFirewallInstalled(
runner: CommandRunner = runCommand,
): Promise<{
available: boolean;
warningMessage?: string;
}> {
try {
await runner("sfw", ["--help"]);
return { available: true };
} catch {
try {
await runner("npm", ["install", "-g", "sfw"]);
await runner("sfw", ["--help"]);
return { available: true };
} catch {
return {
available: false,
warningMessage: SOCKET_FIREWALL_WARNING_MESSAGE,
};
}
}
}
export async function detectPreferredPackageManager(
runner: CommandRunner = runCommand,
): Promise<PackageManager> {
try {
await runner("pnpm", ["--version"]);
return "pnpm";
} catch {
return "npm";
}
}
export function buildAddDependencyCommand(
packages: string[],
packageManager: PackageManager,
useSocketFirewall: boolean,
): { command: string; args: string[] } {
const packageManagerArgs =
packageManager === "pnpm"
? ["add", ...packages]
: ["install", "--legacy-peer-deps", ...packages];
if (useSocketFirewall) {
return {
command: "sfw",
args: [packageManager, ...packageManagerArgs],
};
}
return {
command: packageManager,
args: packageManagerArgs,
};
}
......@@ -333,6 +333,7 @@ const BaseUserSettingsFields = {
enableAutoFixProblems: z.boolean().optional(),
autoExpandPreviewPanel: z.boolean().optional(),
enableChatEventNotifications: z.boolean().optional(),
blockUnsafeNpmPackages: z.boolean().optional(),
enableNativeGit: z.boolean().optional(),
enableMcpServersForBuildMode: z.boolean().optional(),
enableAutoUpdate: z.boolean(),
......
import { describe, expect, it } from "vitest";
import {
SECTION_IDS,
SETTING_IDS,
SETTINGS_SEARCH_INDEX,
} from "./settingsSearchIndex";
describe("SETTINGS_SEARCH_INDEX", () => {
it("includes the block unsafe npm packages experiment", () => {
expect(
SETTINGS_SEARCH_INDEX.find(
(item) => item.id === SETTING_IDS.blockUnsafeNpmPackages,
),
).toEqual({
id: SETTING_IDS.blockUnsafeNpmPackages,
label: "Block unsafe npm packages",
description: "Uses socket.dev to detect unsafe packages and blocks them",
keywords: ["socket", "npm", "firewall", "package", "unsafe", "security"],
sectionId: SECTION_IDS.experiments,
sectionLabel: "Experiments",
});
});
});
......@@ -34,6 +34,7 @@ export const SETTING_IDS = {
supabase: "setting-supabase",
neon: "setting-neon",
nativeGit: "setting-native-git",
blockUnsafeNpmPackages: "setting-block-unsafe-npm-packages",
enableMcpServersForBuildMode: "setting-enable-mcp-servers-for-build-mode",
enableSelectAppFromHomeChatInput:
"setting-enable-select-app-from-home-chat-input",
......@@ -341,6 +342,14 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [
sectionId: SECTION_IDS.experiments,
sectionLabel: "Experiments",
},
{
id: SETTING_IDS.blockUnsafeNpmPackages,
label: "Block unsafe npm packages",
description: "Uses socket.dev to detect unsafe packages and blocks them",
keywords: ["socket", "npm", "firewall", "package", "unsafe", "security"],
sectionId: SECTION_IDS.experiments,
sectionLabel: "Experiments",
},
{
id: SETTING_IDS.enableMcpServersForBuildMode,
......
......@@ -16,8 +16,8 @@ import { updateElectronApp, UpdateSourceType } from "update-electron-app";
import log from "electron-log";
import {
getSettingsFilePath,
readSettings,
writeSettings,
readEffectiveSettings,
} from "./main/settings";
import { handleSupabaseOAuthReturn } from "./supabase_admin/supabase_return_handler";
import { handleDyadProReturn } from "./main/pro";
......@@ -179,7 +179,7 @@ export async function onReady() {
// Cleanup old media files to reclaim disk space
cleanupOldMediaFiles();
const settings = readSettings();
const settings = await readEffectiveSettings();
// Add dyad-apps directory to git safe.directory (required for Windows).
// The trailing /* allows access to all repositories under the named directory.
......
......@@ -15,6 +15,10 @@ import log from "electron-log";
import { DEFAULT_TEMPLATE_ID } from "@/shared/templates";
import { DEFAULT_THEME_ID } from "@/shared/themes";
import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
import {
getRemoteDesktopConfig,
type RemoteDesktopConfig,
} from "@/ipc/shared/remote_desktop_config";
const logger = log.scope("settings");
......@@ -183,6 +187,27 @@ export function readSettings(): UserSettings {
}
}
export function resolveEffectiveSettings(
settings: UserSettings,
remoteConfig: RemoteDesktopConfig | null,
): UserSettings {
if (typeof settings.blockUnsafeNpmPackages === "boolean") {
return settings;
}
return {
...settings,
blockUnsafeNpmPackages:
remoteConfig?.defaults?.blockUnsafeNpmPackages ?? true,
};
}
export async function readEffectiveSettings(): Promise<UserSettings> {
const settings = readSettings();
const remoteConfig = await getRemoteDesktopConfig();
return resolveEffectiveSettings(settings, remoteConfig);
}
export function writeSettings(settings: Partial<UserSettings>): void {
try {
const filePath = getSettingsFilePath();
......
......@@ -34,6 +34,7 @@ import { ZoomSelector } from "@/components/ZoomSelector";
import { LanguageSelector } from "@/components/LanguageSelector";
import { DefaultChatModeSelector } from "@/components/DefaultChatModeSelector";
import { ContextCompactionSwitch } from "@/components/ContextCompactionSwitch";
import { BlockUnsafeNpmPackagesSwitch } from "@/components/BlockUnsafeNpmPackagesSwitch";
import { useSetAtom } from "jotai";
import { activeSettingsSectionAtom } from "@/atoms/viewAtoms";
import { SECTION_IDS, SETTING_IDS } from "@/lib/settingsSearchIndex";
......@@ -195,6 +196,12 @@ export default function SettingsPage() {
a faster, native-Git performance experience.
</div>
</div>
<div
id={SETTING_IDS.blockUnsafeNpmPackages}
className="space-y-1 mt-4"
>
<BlockUnsafeNpmPackagesSwitch />
</div>
<div
id={SETTING_IDS.enableMcpServersForBuildMode}
className="space-y-1 mt-4"
......
......@@ -468,6 +468,7 @@ export async function handleLocalAgentStream(
const pendingUserMessages: UserMessageContentPart[][] = [];
// Store injected messages with their insertion index to re-inject at the same spot each step
const allInjectedMessages: InjectedMessage[] = [];
const warningMessages: string[] = [];
try {
// Get model client
......@@ -556,6 +557,9 @@ export async function handleLocalAgentStream(
todos,
});
},
onWarningMessage: (message) => {
warningMessages.push(message);
},
};
// Build tool set (agent tools + MCP tools)
......@@ -1265,6 +1269,8 @@ export async function handleLocalAgentStream(
chatId: req.chatId,
updatedFiles: !readOnly,
chatSummary: ctx.chatSummary,
warningMessages:
warningMessages.length > 0 ? [...new Set(warningMessages)] : undefined,
} satisfies ChatResponseEnd);
return true; // Success
......@@ -1289,6 +1295,8 @@ export async function handleLocalAgentStream(
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error: `Error: ${getErrorMessage(error)}`,
warningMessages:
warningMessages.length > 0 ? [...new Set(warningMessages)] : undefined,
});
return false; // Error - don't consume quota
}
......
......@@ -46,6 +46,24 @@ import {
import { AgentToolConsent } from "@/lib/schemas";
import { getSupabaseClientCode } from "@/supabase_admin/supabase_context";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { ExecuteAddDependencyError } from "@/ipc/processors/executeAddDependency";
function getToolErrorDisplayDetails(error: unknown): string {
if (error instanceof ExecuteAddDependencyError) {
return error.displayDetails;
}
return error instanceof Error ? error.message : String(error);
}
function getToolErrorSummary(error: unknown): string {
if (error instanceof ExecuteAddDependencyError) {
return error.displaySummary;
}
return error instanceof Error ? error.message : String(error);
}
// Combined tool definitions array
export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
writeFileTool,
......@@ -477,11 +495,11 @@ export function buildAgentToolSet(
return convertToolResultForAiSdk(result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorMessage = getToolErrorSummary(error);
const errorDetails = getToolErrorDisplayDetails(error);
ctx.onXmlComplete(
`<dyad-output type="error" message="Tool '${tool.name}' failed: ${escapeXmlAttr(errorMessage)}">${escapeXmlContent(errorMessage)}</dyad-output>`,
`<dyad-output type="error" message="Tool '${tool.name}' failed: ${escapeXmlAttr(errorMessage)}">${escapeXmlContent(errorDetails)}</dyad-output>`,
);
throw error;
}
......
......@@ -3,7 +3,10 @@ import { eq } from "drizzle-orm";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { db } from "../../../../../../db";
import { messages } from "../../../../../../db/schema";
import { executeAddDependency } from "@/ipc/processors/executeAddDependency";
import {
executeAddDependency,
ExecuteAddDependencyError,
} from "@/ipc/processors/executeAddDependency";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const addDependencySchema = z.object({
......@@ -40,11 +43,23 @@ export const addDependencyTool: ToolDefinition<
);
}
await executeAddDependency({
try {
const result = await executeAddDependency({
packages: args.packages,
message,
appPath: ctx.appPath,
});
for (const warningMessage of result.warningMessages) {
ctx.onWarningMessage?.(warningMessage);
}
} catch (error) {
if (error instanceof ExecuteAddDependencyError) {
for (const warningMessage of error.warningMessages) {
ctx.onWarningMessage?.(warningMessage);
}
}
throw error;
}
return `Successfully installed ${args.packages.join(", ")}`;
},
......
......@@ -88,6 +88,10 @@ export interface AgentContext {
* Call this when todos are updated to show them in the chat input area.
*/
onUpdateTodos: (todos: Todo[]) => void;
/**
* Queues a warning toast to be shown to the user when the turn completes.
*/
onWarningMessage?: (message: string) => void;
}
// ============================================================================
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论