fix(security): harden renderer and desktop execution boundaries
Preserve trusted plugin and automatic ACP permission models. Track release and remaining validation in #546.
This commit is contained in:
1 parent
f6ade70423
commit
6b5027e8b4
30 files changed
+508
-137
No files matched your search
@@ -4,6 +4,7 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, realpat
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { validateTrustedAcpPath } from "./acp-path-store.mjs";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } from "@agentclientprotocol/sdk";
|
||||
@@ -865,12 +866,14 @@ export function createAcpHostRuntime(options = {}) {
|
||||
};
|
||||
|
||||
return {
|
||||
listAdapters() {
|
||||
clearAdapterStatus(id) { latestStatus.delete(id); },
|
||||
listAdapters(configuredPaths = {}) {
|
||||
return ["codex", "claudeCode", "antigravity", "openClaw", "hermesAgent", "grokBuild", "deepseekHarness", "piAgent", "workbuddyCn", "workbuddyIntl"].map((id) => {
|
||||
if (installingIds.has(id)) return { ...adapterShell(id), state: "installing" };
|
||||
const resolved = resolveCommand({ id }, { prepareProxy: false });
|
||||
const customPath = configuredPaths[id];
|
||||
const resolved = resolveCommand({ id, path: customPath }, { prepareProxy: false });
|
||||
return resolved.ok
|
||||
? { ...adapterShell(id), ...(latestStatus.get(id) ?? { state: "not_probed", detail: "not_probed" }), ...(resolved.version ? { version: resolved.version, managed: true } : {}), ...(updateFailures.has(id) ? { updateError: updateFailures.get(id) } : {}) }
|
||||
? { ...adapterShell(id), ...(latestStatus.get(id) ?? { state: "not_probed", detail: "not_probed" }), ...(resolved.version ? { version: resolved.version, managed: true } : {}), ...(customPath ? { customPath, managed: false, version: undefined } : {}), ...(updateFailures.has(id) ? { updateError: updateFailures.get(id) } : {}) }
|
||||
: adapterFromResolution(id, resolved);
|
||||
});
|
||||
},
|
||||
@@ -1136,27 +1139,59 @@ const failureFields = (failure) => (
|
||||
: { state: failure.state }
|
||||
);
|
||||
|
||||
export function registerAcpIpc(ipcMain, runtime = createAcpHostRuntime(), { allowInstall = () => false, allowPrompt = allowInstall } = {}) {
|
||||
export function registerAcpIpc(ipcMain, runtime = createAcpHostRuntime(), {
|
||||
allowInstall = () => false, allowPrompt = allowInstall,
|
||||
adapterPathStore = { get: async () => null, set: async () => {} },
|
||||
chooseAdapterPath = async () => null,
|
||||
validateAdapterPath = validateTrustedAcpPath,
|
||||
} = {}) {
|
||||
let choosing = false;
|
||||
const withRecordedPath = async (input) => {
|
||||
const recorded = (input?.id ?? input?.adapterId) === "antigravity" ? await adapterPathStore.get() : null;
|
||||
return { ...input, path: recorded ? await validateAdapterPath(recorded) : undefined };
|
||||
};
|
||||
const send = (sender, event) => {
|
||||
if (!sender || sender.isDestroyed?.()) return;
|
||||
sender.send("desktop:acp-event", event);
|
||||
};
|
||||
ipcMain.handle("desktop:acp-list", () => runtime.listAdapters());
|
||||
ipcMain.handle("desktop:acp-probe", (_event, input) => runtime.probeAdapter(input));
|
||||
ipcMain.handle("desktop:acp-install", (event, id) => {
|
||||
if (!allowInstall(event.sender)) throw new Error("adapter_install_forbidden");
|
||||
return runtime.installAdapter(id);
|
||||
ipcMain.handle("desktop:acp-list", async (event) => {
|
||||
if (!allowPrompt(event.sender, event)) throw new Error("acp_list_forbidden");
|
||||
const recorded = await adapterPathStore.get();
|
||||
return runtime.listAdapters({ antigravity: recorded }).map((adapter) => adapter.id === "antigravity" && recorded
|
||||
? { ...adapter, customPath: recorded } : adapter);
|
||||
});
|
||||
ipcMain.handle("desktop:acp-authenticate", (event, input) => {
|
||||
if (!allowInstall(event.sender)) throw new Error("adapter_auth_forbidden");
|
||||
return runtime.authenticateAdapter(input);
|
||||
ipcMain.handle("desktop:acp-choose-path", async (event, id) => {
|
||||
if (!allowInstall(event.sender, event) || id !== "antigravity") throw new Error("adapter_path_forbidden");
|
||||
if (choosing || event.sender.isFocused?.() !== true) return null;
|
||||
choosing = true;
|
||||
try {
|
||||
const selected = await chooseAdapterPath();
|
||||
if (!selected) return null;
|
||||
await adapterPathStore.set(selected);
|
||||
runtime.clearAdapterStatus?.("antigravity");
|
||||
return selected;
|
||||
} finally { choosing = false; }
|
||||
});
|
||||
ipcMain.handle("desktop:acp-prompt", (event, input) => {
|
||||
if (!allowPrompt(event.sender)) throw new Error("acp_prompt_forbidden");
|
||||
return runtime.prompt(input, (acpEvent) => send(event.sender, acpEvent));
|
||||
ipcMain.handle("desktop:acp-probe", async (event, input) => {
|
||||
if (!allowPrompt(event.sender, event)) throw new Error("acp_probe_forbidden");
|
||||
return runtime.probeAdapter(await withRecordedPath(input));
|
||||
});
|
||||
ipcMain.handle("desktop:acp-install", async (event, id) => {
|
||||
if (!allowInstall(event.sender, event)) throw new Error("adapter_install_forbidden");
|
||||
const result = await runtime.installAdapter(id);
|
||||
if (id === "antigravity" && result.updated) await adapterPathStore.set(null);
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("desktop:acp-authenticate", async (event, input) => {
|
||||
if (!allowInstall(event.sender, event)) throw new Error("adapter_auth_forbidden");
|
||||
return runtime.authenticateAdapter(await withRecordedPath(input));
|
||||
});
|
||||
ipcMain.handle("desktop:acp-prompt", async (event, input) => {
|
||||
if (!allowPrompt(event.sender, event)) throw new Error("acp_prompt_forbidden");
|
||||
return runtime.prompt(await withRecordedPath(input), (acpEvent) => send(event.sender, acpEvent));
|
||||
});
|
||||
ipcMain.handle("desktop:acp-cancel", (event, requestId) => {
|
||||
if (!allowPrompt(event.sender)) throw new Error("acp_cancel_forbidden");
|
||||
if (!allowPrompt(event.sender, event)) throw new Error("acp_cancel_forbidden");
|
||||
return runtime.cancel(requestId);
|
||||
});
|
||||
return runtime;
|
||||
|
||||
@@ -1297,6 +1297,6 @@ test("desktop preload and main process expose the ACP host", async () => {
|
||||
expect(preload).toContain('ipcRenderer.on("desktop:acp-event"');
|
||||
const handlers = new Map();
|
||||
registerAcpIpc({ handle: (channel, handler) => handlers.set(channel, handler) });
|
||||
expect([...handlers.keys()]).toEqual(["desktop:acp-list", "desktop:acp-probe", "desktop:acp-install", "desktop:acp-authenticate", "desktop:acp-prompt", "desktop:acp-cancel"]);
|
||||
expect(() => handlers.get("desktop:acp-prompt")({ sender: {} }, { prompt: "test" })).toThrow("acp_prompt_forbidden");
|
||||
expect([...handlers.keys()]).toEqual(["desktop:acp-list", "desktop:acp-choose-path", "desktop:acp-probe", "desktop:acp-install", "desktop:acp-authenticate", "desktop:acp-prompt", "desktop:acp-cancel"]);
|
||||
await expect(handlers.get("desktop:acp-prompt")({ sender: {} }, { prompt: "test" })).rejects.toThrow("acp_prompt_forbidden");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const validateTrustedAcpPath = async (filename, { excludedRoot } = {}) => {
|
||||
const selected = await realpath(filename);
|
||||
if (excludedRoot) {
|
||||
const root = await realpath(excludedRoot);
|
||||
const child = path.relative(root, selected);
|
||||
if (!child || (child !== ".." && !child.startsWith(`..${path.sep}`) && !path.isAbsolute(child))) {
|
||||
throw new Error("Choose an ACP executable outside EdgeEver's client data directory");
|
||||
}
|
||||
}
|
||||
const info = await stat(selected);
|
||||
if (!info.isFile()) throw new Error("The ACP executable must be a file");
|
||||
if (process.platform !== "win32") {
|
||||
if (info.mode & 0o022) throw new Error("The ACP executable must not be writable by other users");
|
||||
for (let directory = path.dirname(selected);; directory = path.dirname(directory)) {
|
||||
if ((await stat(directory)).mode & 0o022) throw new Error("The ACP executable directory must not be writable by other users");
|
||||
if (path.dirname(directory) === directory) break;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
// This store must remain outside all renderer-writable resource directories.
|
||||
export const createAcpPathStore = (filename) => {
|
||||
let recorded = null;
|
||||
const ready = readFile(filename, "utf8").then((text) => {
|
||||
const value = JSON.parse(text)?.antigravity;
|
||||
if (typeof value === "string" && path.isAbsolute(value) && !value.includes("\0")) recorded = value;
|
||||
}).catch(() => {});
|
||||
return {
|
||||
async get() { await ready; return recorded; },
|
||||
async set(value) {
|
||||
await ready;
|
||||
await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${filename}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, JSON.stringify({ antigravity: value }), { mode: 0o600 });
|
||||
await rename(temporary, filename);
|
||||
recorded = value;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,26 +1,20 @@
|
||||
import { nodePublicFetch } from "@edgeever/public-network/node";
|
||||
import { validatePublicUrl } from "@edgeever/public-network/policy";
|
||||
|
||||
const MAX_BODY_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_AUDIO_REQUEST_BYTES = 25_000_000;
|
||||
const MAX_CONCURRENT = 2;
|
||||
|
||||
const blockedHost = (hostname) => {
|
||||
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (!host || host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) return true;
|
||||
if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.|169\.254\.)/.test(host)) return true;
|
||||
if (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const validateAiProviderUrl = (value) => {
|
||||
const url = new URL(value);
|
||||
const url = validatePublicUrl(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
||||
throw new Error("AI provider requests must use HTTPS without credentials.");
|
||||
}
|
||||
if (url.port && url.port !== "443") throw new Error("AI provider requests must use port 443.");
|
||||
if (blockedHost(url.hostname)) throw new Error("AI provider host is not allowed.");
|
||||
return url;
|
||||
};
|
||||
|
||||
export const createAiDirectRuntime = ({ fetchImpl = fetch, maxConcurrent = MAX_CONCURRENT } = {}) => {
|
||||
export const createAiDirectRuntime = ({ fetchImpl = nodePublicFetch, maxConcurrent = MAX_CONCURRENT } = {}) => {
|
||||
const active = new Map();
|
||||
return {
|
||||
async open(requestId, input, { onData, onEnd, onError }) {
|
||||
@@ -47,6 +41,9 @@ export const createAiDirectRuntime = ({ fetchImpl = fetch, maxConcurrent = MAX_C
|
||||
if (typeof key !== "string" || typeof value !== "string" || /[\r\n\0]/.test(key) || /[\r\n\0]/.test(value)) {
|
||||
throw new Error("Invalid AI provider header.");
|
||||
}
|
||||
if (["host", "connection", "transfer-encoding", "content-length", "upgrade", "proxy-authorization"].includes(key.toLowerCase())) {
|
||||
throw new Error("Invalid AI provider header.");
|
||||
}
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ test("desktop AI transport sends multipart audio bytes directly to the speech pr
|
||||
const form = new FormData();
|
||||
form.set("model", "whisper-1");
|
||||
form.set("file", new File(["audio bytes"], "clip.mp3", { type: "audio/mpeg" }));
|
||||
const request = new Request("https://speech.example/v1/audio/transcriptions", { method: "POST", body: form });
|
||||
const request = new Request("https://api.openai.com/v1/audio/transcriptions", { method: "POST", body: form });
|
||||
const headers = Object.fromEntries(request.headers);
|
||||
const bodyBytes = new Uint8Array(await request.arrayBuffer());
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
@@ -73,12 +73,12 @@ test("desktop AI transport sends multipart audio bytes directly to the speech pr
|
||||
onError: reject,
|
||||
}).catch(reject);
|
||||
});
|
||||
expect(received.url).toBe("https://speech.example/v1/audio/transcriptions");
|
||||
expect(received.url).toBe("https://api.openai.com/v1/audio/transcriptions");
|
||||
expect(received.body).toBeInstanceOf(Uint8Array);
|
||||
expect(received.headers["content-type"]).toMatch(/^multipart\/form-data; boundary=/);
|
||||
expect(JSON.parse(result).text).toBe("recognized");
|
||||
await expect(runtime.open("speech-invalid", {
|
||||
url: "https://speech.example/v1/chat/completions",
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "",
|
||||
|
||||
+113
-65
@@ -7,8 +7,9 @@ import { execFile, spawn } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { release as operatingSystemRelease } from "node:os";
|
||||
import { SidecarRpcClient } from "./rpc.mjs";
|
||||
import { instanceOrigin, encodeSessionCredential, decodeSessionCredential, credentialForUrl } from "./session-credential.mjs";
|
||||
import { resourceRequestHeaders } from "./resource-request.mjs";
|
||||
import { downloadContentDispositionFromRequest, isSafeResourceId, parseByteRangeHeader, resourceIdFromRequest } from "./resource-url.mjs";
|
||||
import { downloadContentDispositionFromRequest, isSafeResourceId, parseByteRangeHeader, resourceIdFromRequest, resourceResponseHeaders } from "./resource-url.mjs";
|
||||
import { isSupportedAssociatedFile } from "./file-association.mjs";
|
||||
import { createWeChatShareController } from "./wechat-share-import.mjs";
|
||||
import { enableMacShareExtension } from "./share-extension-registration.mjs";
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
mountedInstallerCandidates,
|
||||
} from "./installation-location.mjs";
|
||||
import { userDataDirectoryFromArguments } from "./user-data-directory.mjs";
|
||||
import { isAllowedPrintPreviewUrl } from "./window-open-policy.mjs";
|
||||
import { isAllowedAppNavigation, isAllowedPrintPreviewUrl } from "./window-open-policy.mjs";
|
||||
import { showWindow } from "./window-visibility.mjs";
|
||||
import { globalShortcutAccelerator, normalizeGlobalShortcutBinding, replaceGlobalShortcut, toggleMainWindow } from "./global-shortcut.mjs";
|
||||
import { trayIconPath } from "./tray-icon.mjs";
|
||||
@@ -54,6 +55,7 @@ import { inspectWindowsTaskbarShortcuts } from "./windows-taskbar-diagnostics.mj
|
||||
import { testPicGoConnection, createPicGoUploadQueue } from "./picgo.mjs";
|
||||
import { createPluginPublicNetworkRuntime } from "./plugin-public-network.mjs";
|
||||
import { createAiDirectRuntime } from "./ai-direct.mjs";
|
||||
import { createAcpPathStore, validateTrustedAcpPath } from "./acp-path-store.mjs";
|
||||
import { createAcpHostRuntime, registerAcpIpc } from "./acp-host.mjs";
|
||||
import { shouldQuitAfterAllWindowsClosed } from "./window-lifecycle.mjs";
|
||||
import {
|
||||
@@ -183,6 +185,8 @@ const crashMarkerPath = () => join(app.getPath("userData"), "last-session-active
|
||||
const installationMarkerPath = () => join(app.getPath("userData"), "installation-confirmed");
|
||||
const logPath = () => join(app.getPath("userData"), "logs", "desktop.log");
|
||||
let desktopSessionToken = "";
|
||||
let desktopSessionTokenOrigin = null;
|
||||
let instanceChangePending = false;
|
||||
let configuredGlobalShortcut = null;
|
||||
let registeredGlobalShortcut = null;
|
||||
let globalShortcutUpdate = Promise.resolve();
|
||||
@@ -558,24 +562,40 @@ const loadConfiguredApiBaseUrl = async () => {
|
||||
const loadDesktopSessionToken = async () => {
|
||||
try {
|
||||
const encrypted = await readFile(sessionTokenPath());
|
||||
desktopSessionToken = safeStorage.decryptString(encrypted);
|
||||
const plaintext = safeStorage.decryptString(encrypted);
|
||||
const credential = decodeSessionCredential(plaintext, configuredApiBaseUrl);
|
||||
desktopSessionToken = credentialForUrl(credential.token, credential.origin, configuredApiBaseUrl);
|
||||
desktopSessionTokenOrigin = credential.origin;
|
||||
if (desktopSessionToken && !plaintext.startsWith("{")) {
|
||||
await saveDesktopSessionToken(desktopSessionToken);
|
||||
}
|
||||
} catch {
|
||||
// Existing installations have no main-process credential until the
|
||||
// renderer migrates the legacy localStorage token after upgrading.
|
||||
desktopSessionToken = "";
|
||||
desktopSessionTokenOrigin = null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveDesktopSessionToken = async (value) => {
|
||||
let sessionCredentialWrite = Promise.resolve();
|
||||
const saveDesktopSessionToken = (value) => {
|
||||
const operation = sessionCredentialWrite.then(() => persistDesktopSessionToken(value));
|
||||
sessionCredentialWrite = operation.catch(() => {});
|
||||
return operation;
|
||||
};
|
||||
|
||||
const persistDesktopSessionToken = async (value) => {
|
||||
const normalized = typeof value === "string" ? value.trim() : "";
|
||||
if (normalized.length > 4096) throw new Error("Desktop session token is too long");
|
||||
desktopSessionToken = normalized;
|
||||
const encrypted = safeStorage.encryptString(normalized);
|
||||
const origin = instanceOrigin(configuredApiBaseUrl);
|
||||
const encrypted = safeStorage.encryptString(encodeSessionCredential(normalized, configuredApiBaseUrl));
|
||||
const temporaryPath = `${sessionTokenPath()}.tmp`;
|
||||
await writeFile(temporaryPath, encrypted, { mode: 0o600 });
|
||||
await restrictFile(temporaryPath);
|
||||
await rename(temporaryPath, sessionTokenPath());
|
||||
await restrictFile(sessionTokenPath());
|
||||
desktopSessionToken = normalized;
|
||||
desktopSessionTokenOrigin = origin;
|
||||
};
|
||||
|
||||
const pendingDesktopCommands = [];
|
||||
@@ -903,12 +923,8 @@ const handleResourceProtocolRequest = async (request) => {
|
||||
const fileResponse = async (path, contentType, rangeHeader) => {
|
||||
const { size } = await stat(path);
|
||||
const range = parseByteRangeHeader(rangeHeader, size);
|
||||
const headers = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": contentType || "application/octet-stream",
|
||||
});
|
||||
if (downloadDisposition) headers.set("Content-Disposition", downloadDisposition);
|
||||
const headers = resourceResponseHeaders(contentType, downloadDisposition);
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
if (range.kind === "invalid") {
|
||||
headers.set("Content-Range", `bytes */${size}`);
|
||||
return new Response(null, { status: 416, headers });
|
||||
@@ -939,18 +955,16 @@ const handleResourceProtocolRequest = async (request) => {
|
||||
if (!configuredApiBaseUrl) return new Response("Resource is not cached", { status: 504 });
|
||||
const sourceUrl = `${configuredApiBaseUrl}/api/v1/resources/${encodeURIComponent(resourceId)}/blob`;
|
||||
try {
|
||||
const sessionToken = credentialForUrl(desktopSessionToken, desktopSessionTokenOrigin, sourceUrl);
|
||||
const cookies = await session.defaultSession.cookies.get({ url: sourceUrl });
|
||||
const headers = resourceRequestHeaders({ cookies, sessionToken: desktopSessionToken });
|
||||
const headers = resourceRequestHeaders({ cookies, sessionToken });
|
||||
const rangeHeader = request.headers.get("range");
|
||||
if (rangeHeader) headers.set("range", rangeHeader);
|
||||
const response = await net.fetch(sourceUrl, { headers });
|
||||
const response = await net.fetch(sourceUrl, { headers, redirect: "error" });
|
||||
if (!response.ok) return new Response("Resource request failed", { status: response.status });
|
||||
if (response.status === 206) {
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": response.headers.get("accept-ranges") || "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": response.headers.get("content-type") || "application/octet-stream",
|
||||
});
|
||||
const responseHeaders = resourceResponseHeaders(response.headers.get("content-type"), downloadDisposition);
|
||||
responseHeaders.set("Accept-Ranges", response.headers.get("accept-ranges") || "bytes");
|
||||
for (const name of ["content-length", "content-range", "etag", "last-modified"]) {
|
||||
const value = response.headers.get(name);
|
||||
if (value) responseHeaders.set(name, value);
|
||||
@@ -1027,13 +1041,8 @@ const registerResourceProtocol = () => {
|
||||
}
|
||||
if (size === null) throw new Error("Staged bytes are missing");
|
||||
const stream = createReadStream(path);
|
||||
const headers = new Headers({
|
||||
"Content-Type": metadata.type || "application/octet-stream",
|
||||
"Content-Length": String(size),
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
const downloadDisposition = downloadContentDispositionFromRequest(request.url);
|
||||
if (downloadDisposition) headers.set("Content-Disposition", downloadDisposition);
|
||||
const headers = resourceResponseHeaders(metadata.type, downloadContentDispositionFromRequest(request.url));
|
||||
headers.set("Content-Length", String(size));
|
||||
return new Response(Readable.toWeb(stream), {
|
||||
headers,
|
||||
});
|
||||
@@ -1527,6 +1536,34 @@ const createWindow = async () => {
|
||||
void writeDiagnostic("renderer.responsive");
|
||||
});
|
||||
|
||||
const rendererEntryUrl = app.isPackaged && !process.env.EDGE_EVER_DESKTOP_WEB_URL
|
||||
? (usePrivateAppProtocol ? DESKTOP_APP_ENTRY_URL : pathToFileURL(join(process.resourcesPath, "web/index.html")).href)
|
||||
: webUrl;
|
||||
const guardNavigation = (event, url) => {
|
||||
if (isAllowedAppNavigation(url, rendererEntryUrl)) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
mainWindow.webContents.on("will-navigate", guardNavigation);
|
||||
mainWindow.webContents.on("will-redirect", guardNavigation);
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (isAllowedPrintPreviewUrl(url, mainWindow.webContents.getURL())) {
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: fileURLToPath(new URL("../preload/print.cjs", import.meta.url)),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
try {
|
||||
if (app.isPackaged && !process.env.EDGE_EVER_DESKTOP_WEB_URL) {
|
||||
if (usePrivateAppProtocol) await mainWindow.loadURL(DESKTOP_APP_ENTRY_URL);
|
||||
@@ -1540,28 +1577,6 @@ const createWindow = async () => {
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith("edgeever-resource://") || url.startsWith("edgeever-staged://")) return { action: "allow" };
|
||||
if (isAllowedPrintPreviewUrl(url, mainWindow.webContents.getURL())) {
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||
if (url.startsWith(webUrl) || url.startsWith(`${DESKTOP_APP_ORIGIN}/`) || url.startsWith("edgeever-resource://") || url.startsWith("edgeever-staged://")) return;
|
||||
event.preventDefault();
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) void shell.openExternal(url);
|
||||
});
|
||||
buildApplicationMenu();
|
||||
};
|
||||
|
||||
@@ -1709,16 +1724,21 @@ const startApplication = async () => {
|
||||
if (rendererStartupGuard?.complete()) void writeDiagnostic("renderer.bootstrap-ready");
|
||||
});
|
||||
ipcMain.on("desktop:api-base-url-sync", (event) => { event.returnValue = configuredApiBaseUrl; });
|
||||
ipcMain.on("desktop:session-token-sync", (event) => { event.returnValue = desktopSessionToken; });
|
||||
ipcMain.on("desktop:session-token-sync", (event) => {
|
||||
event.returnValue = event.sender === mainWindow?.webContents && event.senderFrame === event.sender.mainFrame
|
||||
? credentialForUrl(desktopSessionToken, desktopSessionTokenOrigin, configuredApiBaseUrl) : "";
|
||||
});
|
||||
ipcMain.on("desktop:recovered-after-abnormal-exit-sync", (event) => { event.returnValue = recoveredAfterAbnormalExit; });
|
||||
ipcMain.handle("desktop:copy-text", (_event, value) => writeTextClipboard(clipboard, value));
|
||||
ipcMain.handle("desktop:copy-html", (_event, input) => writeRichClipboard(clipboard, ClipboardItem, input));
|
||||
ipcMain.handle("desktop:copy-image", (_event, bytes) => writeImageClipboard(clipboard, ClipboardItem, bytes));
|
||||
ipcMain.handle("desktop:set-session-token", async (_event, value) => {
|
||||
ipcMain.handle("desktop:set-session-token", async (event, value) => {
|
||||
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame || instanceChangePending) throw new Error("Session update forbidden");
|
||||
await saveDesktopSessionToken(value);
|
||||
return { stored: Boolean(desktopSessionToken) };
|
||||
});
|
||||
ipcMain.handle("desktop:clear-session-token", async () => {
|
||||
ipcMain.handle("desktop:clear-session-token", async (event) => {
|
||||
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame || instanceChangePending) throw new Error("Session update forbidden");
|
||||
scheduledTaskScheduler.clear();
|
||||
await saveDesktopSessionToken("");
|
||||
return { stored: false };
|
||||
@@ -1783,7 +1803,7 @@ const startApplication = async () => {
|
||||
: join(projectRoot, "scripts", "edgeever-mcp-stdio.mjs"),
|
||||
mcpAccess: () => {
|
||||
const baseUrl = configuredApiBaseUrl;
|
||||
const sessionToken = desktopSessionToken;
|
||||
const sessionToken = credentialForUrl(desktopSessionToken, desktopSessionTokenOrigin, baseUrl);
|
||||
const accountId = activeAccountId;
|
||||
return {
|
||||
baseUrl,
|
||||
@@ -1795,7 +1815,16 @@ const startApplication = async () => {
|
||||
isCurrent: () => configuredApiBaseUrl === baseUrl && desktopSessionToken === sessionToken && activeAccountId === accountId,
|
||||
};
|
||||
},
|
||||
}), { allowInstall: (sender) => sender === mainWindow?.webContents });
|
||||
}), {
|
||||
allowInstall: (sender, event) => sender === mainWindow?.webContents && event?.senderFrame === sender.mainFrame,
|
||||
adapterPathStore: createAcpPathStore(join(app.getPath("userData"), "acp-adapters", "chosen-path.json")),
|
||||
validateAdapterPath: (filename) => validateTrustedAcpPath(filename, { excludedRoot: app.getPath("userData") }),
|
||||
chooseAdapterPath: async () => {
|
||||
const selection = await dialog.showOpenDialog(mainWindow, { properties: ["openFile"], title: app.getLocale().startsWith("zh") ? "选择受信任的 Antigravity ACP 程序" : "Select a trusted Antigravity ACP executable" });
|
||||
if (selection.canceled || !selection.filePaths[0]) return null;
|
||||
return validateTrustedAcpPath(selection.filePaths[0], { excludedRoot: app.getPath("userData") });
|
||||
},
|
||||
});
|
||||
await acpRuntime.pruneAdapters().catch(() => {});
|
||||
const refreshAdapters = () => { void acpRuntime.installDetected().catch(() => []).then(() => acpRuntime.updateInstalled()).catch(() => {}); };
|
||||
const firstAdapterRefresh = setTimeout(refreshAdapters, 10_000);
|
||||
@@ -1882,24 +1911,43 @@ const startApplication = async () => {
|
||||
setTimeout(() => app.exit(0), 50).unref();
|
||||
return { scheduled: true };
|
||||
});
|
||||
ipcMain.handle("desktop:set-api-base-url", async (_event, value) => {
|
||||
ipcMain.handle("desktop:set-api-base-url", async (event, value) => {
|
||||
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error("Instance changes must come from the main window");
|
||||
if (instanceChangePending) throw new Error("An instance change is already in progress");
|
||||
const normalized = typeof value === "string" ? value.trim().replace(/\/$/, "") : "";
|
||||
if (normalized) {
|
||||
let parsed;
|
||||
try { parsed = new URL(normalized); } catch { throw new Error("Desktop API URL must be a valid HTTP(S) URL"); }
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Desktop API URL must use HTTP(S)");
|
||||
if (!instanceOrigin(parsed.href) || parsed.hash || parsed.search) throw new Error("Desktop API URL must use HTTP(S) without credentials, query, or fragment");
|
||||
}
|
||||
if (normalized === configuredApiBaseUrl) return configuredApiBaseUrl;
|
||||
scheduledTaskScheduler.clear();
|
||||
configuredApiBaseUrl = normalized;
|
||||
await writeFile(instanceUrlPath(), configuredApiBaseUrl);
|
||||
if (sidecar) {
|
||||
await stopSidecar();
|
||||
const nextSidecar = await startSidecar(activeAccountId);
|
||||
if (!nextSidecar) throw new Error("EdgeEver sidecar is unavailable after changing instance");
|
||||
await nextSidecar.waitUntilReady();
|
||||
}
|
||||
return configuredApiBaseUrl;
|
||||
instanceChangePending = true;
|
||||
try {
|
||||
if (configuredApiBaseUrl && instanceOrigin(normalized) !== instanceOrigin(configuredApiBaseUrl)) {
|
||||
const chinese = app.getLocale().startsWith("zh");
|
||||
const answer = await dialog.showMessageBox(mainWindow, {
|
||||
type: "warning",
|
||||
title: chinese ? "切换 EdgeEver 实例" : "Switch EdgeEver instance",
|
||||
message: chinese ? "切换实例后需要重新登录。" : "You will need to sign in again after switching instances.",
|
||||
detail: `${configuredApiBaseUrl} → ${normalized}`,
|
||||
buttons: chinese ? ["取消", "切换实例"] : ["Cancel", "Switch instance"], defaultId: 0, cancelId: 0,
|
||||
});
|
||||
if (answer.response !== 1) throw new Error("Instance change cancelled");
|
||||
await saveDesktopSessionToken("");
|
||||
}
|
||||
scheduledTaskScheduler.clear();
|
||||
const temporaryInstancePath = `${instanceUrlPath()}.tmp`;
|
||||
await writeFile(temporaryInstancePath, normalized, { mode: 0o600 });
|
||||
await rename(temporaryInstancePath, instanceUrlPath());
|
||||
configuredApiBaseUrl = normalized;
|
||||
if (sidecar) {
|
||||
await stopSidecar();
|
||||
const nextSidecar = await startSidecar(activeAccountId);
|
||||
if (!nextSidecar) throw new Error("EdgeEver sidecar is unavailable after changing instance");
|
||||
await nextSidecar.waitUntilReady();
|
||||
}
|
||||
return configuredApiBaseUrl;
|
||||
} finally { instanceChangePending = false; }
|
||||
});
|
||||
ipcMain.handle("desktop:update-status", () => desktopUpdateStatus());
|
||||
ipcMain.handle("desktop:check-update", async () => {
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
export const isSafeResourceId = (value) =>
|
||||
typeof value === "string" && value.length > 0 && value.length <= 160 && /^[A-Za-z0-9._~-]+$/.test(value) && value !== "." && value !== "..";
|
||||
|
||||
const INLINE_RESOURCE_TYPES = new Set([
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/bmp",
|
||||
"image/x-icon", "application/pdf", "audio/mpeg", "audio/mp4", "audio/ogg",
|
||||
"audio/wav", "audio/webm", "video/mp4", "video/webm", "video/ogg",
|
||||
]);
|
||||
|
||||
export const resourceResponseHeaders = (contentType, disposition = null) => {
|
||||
const type = typeof contentType === "string" ? contentType.split(";")[0].trim().toLowerCase() : "";
|
||||
const inline = INLINE_RESOURCE_TYPES.has(type);
|
||||
const headers = new Headers({
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": inline ? type : "application/octet-stream",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
});
|
||||
if (disposition || !inline) headers.set("Content-Disposition", disposition || "attachment");
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const resourceIdFromRequest = (requestUrl) => {
|
||||
try {
|
||||
const url = new URL(requestUrl);
|
||||
@@ -70,11 +89,8 @@ export const parseByteRangeHeader = (header, totalSize) => {
|
||||
|
||||
export const cachedResourceResponse = (bytes, contentType, rangeHeader) => {
|
||||
const range = parseByteRangeHeader(rangeHeader, bytes.byteLength);
|
||||
const headers = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": contentType || "application/octet-stream",
|
||||
});
|
||||
const headers = resourceResponseHeaders(contentType);
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
|
||||
if (range.kind === "invalid") {
|
||||
headers.set("Content-Range", `bytes */${bytes.byteLength}`);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerAcpIpc } from "./acp-host.mjs";
|
||||
import { createAcpPathStore, validateTrustedAcpPath } from "./acp-path-store.mjs";
|
||||
import { resourceResponseHeaders } from "./resource-url.mjs";
|
||||
import { isAllowedAppNavigation } from "./window-open-policy.mjs";
|
||||
import { credentialForUrl, decodeSessionCredential, encodeSessionCredential } from "./session-credential.mjs";
|
||||
import { validateAiProviderUrl } from "./ai-direct.mjs";
|
||||
import { createPublicLookup } from "@edgeever/public-network/node";
|
||||
|
||||
test("ACP ignores caller paths on all execution channels and preserves main-owned choices across registration", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "edgeever-acp-boundary-"));
|
||||
try {
|
||||
const filename = join(directory, "chosen.json");
|
||||
const sender = { isFocused: () => true };
|
||||
const calls = [];
|
||||
const runtime = {
|
||||
listAdapters: () => [{ id: "antigravity" }],
|
||||
probeAdapter: (input) => calls.push(input),
|
||||
authenticateAdapter: (input) => calls.push(input),
|
||||
prompt: (input) => calls.push(input),
|
||||
};
|
||||
const register = () => {
|
||||
const handlers = new Map();
|
||||
registerAcpIpc({ handle: (name, handler) => handlers.set(name, handler) }, runtime, {
|
||||
allowInstall: (value) => value === sender,
|
||||
adapterPathStore: createAcpPathStore(filename),
|
||||
chooseAdapterPath: async () => "/user/chosen/acp",
|
||||
validateAdapterPath: async value => value,
|
||||
});
|
||||
return handlers;
|
||||
};
|
||||
const channels = ["desktop:acp-probe", "desktop:acp-authenticate", "desktop:acp-prompt"];
|
||||
const handlers = register();
|
||||
for (const channel of channels) {
|
||||
const input = { id: "antigravity", adapterId: "antigravity", path: "/attacker/payload" };
|
||||
await expect(handlers.get(channel)({ sender: {} }, input)).rejects.toThrow(/forbidden/);
|
||||
await handlers.get(channel)({ sender }, input);
|
||||
expect(calls.at(-1).path).toBeUndefined();
|
||||
}
|
||||
sender.isFocused = () => false;
|
||||
expect(await handlers.get("desktop:acp-choose-path")({ sender }, "antigravity")).toBeNull();
|
||||
sender.isFocused = () => true;
|
||||
await handlers.get("desktop:acp-choose-path")({ sender }, "antigravity");
|
||||
expect(JSON.parse(await readFile(filename, "utf8")).antigravity).toBe("/user/chosen/acp");
|
||||
if (process.platform !== "win32") expect((await stat(filename)).mode & 0o777).toBe(0o600);
|
||||
const restarted = register();
|
||||
for (const channel of channels) {
|
||||
await restarted.get(channel)({ sender }, { id: "antigravity", adapterId: "antigravity", path: "/attacker/payload" });
|
||||
expect(calls.at(-1).path).toBe("/user/chosen/acp");
|
||||
}
|
||||
await restarted.get(channels[0])({ sender }, { id: "codex", path: "/attacker/payload" });
|
||||
expect(calls.at(-1).path).toBeUndefined();
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("privileged window cannot navigate to resource documents or origin-prefix spoofs", () => {
|
||||
for (const app of ["http://127.0.0.1:5173/", "edgeever-app://app/index.html", "file:///app/web/index.html"]) {
|
||||
expect(isAllowedAppNavigation(app, app)).toBe(true);
|
||||
for (const target of ["http://127.0.0.1:5173@evil.example/", "http://127.0.0.1:51730/", "edgeever-app://app.evil/index.html", "edgeever-resource://resource/html", "edgeever-staged://html", "file:///etc/passwd"]) {
|
||||
expect(isAllowedAppNavigation(target, app)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP chooser permits one focused request and cancelled selection records nothing", async () => {
|
||||
const handlers = new Map();
|
||||
const sender = { isFocused: () => true };
|
||||
let finish;
|
||||
let choices = 0;
|
||||
let writes = 0;
|
||||
registerAcpIpc({ handle: (name, handler) => handlers.set(name, handler) }, {}, {
|
||||
allowInstall: (value) => value === sender,
|
||||
chooseAdapterPath: () => { choices++; return new Promise((resolve) => { finish = resolve; }); },
|
||||
adapterPathStore: { get: async () => null, set: async () => { writes++; } },
|
||||
});
|
||||
const choose = handlers.get("desktop:acp-choose-path");
|
||||
const pending = choose({ sender }, "antigravity");
|
||||
expect(await choose({ sender }, "antigravity")).toBeNull();
|
||||
expect(choices).toBe(1);
|
||||
finish(null);
|
||||
expect(await pending).toBeNull();
|
||||
expect(writes).toBe(0);
|
||||
});
|
||||
|
||||
test("stored ACP programs are revalidated before the runtime can execute them", async () => {
|
||||
const handlers = new Map();
|
||||
let executed = false;
|
||||
registerAcpIpc({ handle: (name, handler) => handlers.set(name, handler) }, {
|
||||
probeAdapter: () => { executed = true; },
|
||||
}, {
|
||||
allowInstall: () => true,
|
||||
adapterPathStore: { get: async () => "/missing/previous-choice", set: async () => {} },
|
||||
});
|
||||
await expect(handlers.get("desktop:acp-probe")({ sender: {} }, { id: "antigravity", path: "/attacker" })).rejects.toThrow();
|
||||
expect(executed).toBe(false);
|
||||
});
|
||||
|
||||
test("resource responses never inline active documents, including MIME variants", () => {
|
||||
for (const type of ["text/html", "Text/HTML; charset=utf-8", "image/svg+xml", "application/xhtml+xml", "text/plain", "application/octet-stream"]) {
|
||||
const headers = resourceResponseHeaders(type);
|
||||
expect(headers.get("content-type")).toBe("application/octet-stream");
|
||||
expect(headers.get("content-disposition")).toBe("attachment");
|
||||
expect(headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(headers.get("content-security-policy")).toContain("sandbox");
|
||||
}
|
||||
for (const type of ["image/png", "application/pdf", "video/mp4", "audio/mpeg"]) {
|
||||
expect(resourceResponseHeaders(type).get("content-type")).toBe(type);
|
||||
expect(resourceResponseHeaders(type).get("content-disposition")).toBeNull();
|
||||
expect(resourceResponseHeaders(type, 'attachment; filename="test"').get("content-disposition")).toContain("test");
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy credentials migrate to an origin-bound record and cannot follow an instance switch", () => {
|
||||
const previous = decodeSessionCredential("legacy-test-token", "https://notes.example.org");
|
||||
expect(credentialForUrl(previous.token, previous.origin, "https://notes.example.org/api/v1")).toBe("legacy-test-token");
|
||||
const restored = decodeSessionCredential(encodeSessionCredential(previous.token, previous.origin), "https://attacker.example.org");
|
||||
expect(credentialForUrl(restored.token, restored.origin, "https://attacker.example.org/blob")).toBe("");
|
||||
expect(credentialForUrl(restored.token, restored.origin, "http://notes.example.org/blob")).toBe("");
|
||||
expect(credentialForUrl(restored.token, restored.origin, "https://notes.example.org:8443/blob")).toBe("");
|
||||
expect(decodeSessionCredential('{"version":99}', previous.origin).token).toBe("");
|
||||
});
|
||||
|
||||
test("AI transport rejects mapped IPv6, trailing-dot names and non-public hostnames", async () => {
|
||||
for (const host of ["[::ffff:127.0.0.1]", "[::ffff:169.254.169.254]", "[::ffff:10.0.0.5]", "localhost.", "service.internal", "service.local", "localhost", "127.0.0.1"]) {
|
||||
expect(() => validateAiProviderUrl(`https://${host}/v1`)).toThrow();
|
||||
}
|
||||
const lookup = createPublicLookup((_host, _options, callback) => callback(null, [{ address: "127.0.0.1", family: 4 }]));
|
||||
await expect(new Promise((resolve, reject) => lookup("provider.example.org", {}, (error, address) => error ? reject(error) : resolve(address))))
|
||||
.rejects.toThrow(/public addresses/);
|
||||
const mixed = createPublicLookup((_host, _options, callback) => callback(null, [{ address: "8.8.8.8", family: 4 }, { address: "::ffff:127.0.0.1", family: 6 }]));
|
||||
await expect(new Promise((resolve, reject) => mixed("provider.example.org", {}, (error, address) => error ? reject(error) : resolve(address))))
|
||||
.rejects.toThrow(/public addresses/);
|
||||
});
|
||||
|
||||
test("custom ACP executables cannot be anchored in renderer-writable client storage", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "edgeever-acp-storage-"));
|
||||
try {
|
||||
const filename = join(directory, "..payload.bin");
|
||||
await writeFile(filename, "test", { mode: 0o700 });
|
||||
await expect(validateTrustedAcpPath(filename, { excludedRoot: directory })).rejects.toThrow(/client data directory/);
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export const instanceOrigin = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password ? url.origin : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
export const encodeSessionCredential = (token, baseUrl) => JSON.stringify({ version: 1, token, origin: instanceOrigin(baseUrl) });
|
||||
|
||||
export const decodeSessionCredential = (plaintext, baseUrl) => {
|
||||
// Previous desktop releases encrypted the bare token. Bind that legacy value
|
||||
// only to the instance loaded at startup, before renderer IPC is registered.
|
||||
if (!plaintext.startsWith("{")) return { token: plaintext, origin: instanceOrigin(baseUrl) };
|
||||
try {
|
||||
const value = JSON.parse(plaintext);
|
||||
if (value.version === 1 && typeof value.token === "string" && value.token.length <= 4096
|
||||
&& typeof value.origin === "string" && instanceOrigin(value.origin) === value.origin) return value;
|
||||
} catch {}
|
||||
return { token: "", origin: null };
|
||||
};
|
||||
|
||||
export const credentialForUrl = (token, origin, url) => origin && origin === instanceOrigin(url) ? token : "";
|
||||
@@ -143,7 +143,11 @@ const readZipEntries = (bytes) => {
|
||||
const start = entry.localOffset + 30 + readUInt16(data, entry.localOffset + 26) + readUInt16(data, entry.localOffset + 28);
|
||||
const compressed = data.subarray(start, start + entry.compressedSize);
|
||||
if (compressed.length !== entry.compressedSize) throw new WeChatArchiveError("invalid");
|
||||
const inflated = entry.method === 0 ? Buffer.from(compressed) : inflateRawSync(compressed);
|
||||
if (entry.method === 0 && entry.compressedSize !== entry.expandedSize) throw new WeChatArchiveError("invalid");
|
||||
let inflated;
|
||||
try {
|
||||
inflated = entry.method === 0 ? Buffer.from(compressed) : inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.expandedSize) });
|
||||
} catch { throw new WeChatArchiveError("invalid"); }
|
||||
if (inflated.length !== entry.expandedSize || crc32(inflated) !== entry.crc) throw new WeChatArchiveError("invalid");
|
||||
return { name: entry.name, data: inflated };
|
||||
});
|
||||
|
||||
@@ -39,12 +39,12 @@ const zipOf = (entries) => {
|
||||
const crc = crc32(data);
|
||||
const local = Buffer.concat([
|
||||
u32(0x04034b50), u16(20), u16(0x0800), u16(method), u16(0), u16(0),
|
||||
u32(crc), u32(compressed.length), u32(data.length), u16(name.length), u16(0),
|
||||
u32(crc), u32(compressed.length), u32(entry.declaredSize ?? data.length), u16(name.length), u16(0),
|
||||
name, compressed,
|
||||
]);
|
||||
const central = Buffer.concat([
|
||||
u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(method), u16(0), u16(0),
|
||||
u32(crc), u32(compressed.length), u32(data.length), u16(name.length), u16(0), u16(0),
|
||||
u32(crc), u32(compressed.length), u32(entry.declaredSize ?? data.length), u16(name.length), u16(0), u16(0),
|
||||
u16(0), u16(0), u32(0), u32(offset), name,
|
||||
]);
|
||||
locals.push(local);
|
||||
@@ -182,3 +182,10 @@ describe("WeChat chat archive", () => {
|
||||
expect(() => wechatChatNoteFromArchive(zipOf([{ name: "../聊天记录.txt", data: transcript }]))).toThrow(WeChatArchiveError);
|
||||
});
|
||||
});
|
||||
|
||||
test("bounds actual deflate output even when the declared size is forged", () => {
|
||||
const archive = zipOf([{ name: "聊天记录.txt", data: Buffer.alloc(8 * 1024 * 1024, 65), method: 8, declaredSize: 1 }]);
|
||||
expect(() => wechatChatNoteFromArchive(archive)).toThrow(WeChatArchiveError);
|
||||
const valid = zipOf([{ name: "聊天记录.txt", data: transcript, method: 8 }]);
|
||||
expect(wechatChatNoteFromArchive(valid).markdown).toContain("鱼");
|
||||
});
|
||||
@@ -1,10 +1,22 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
export const isAllowedAppNavigation = (targetUrl, appUrl) => {
|
||||
try {
|
||||
const target = new URL(targetUrl);
|
||||
const expected = new URL(appUrl);
|
||||
if (target.username || target.password || target.protocol !== expected.protocol) return false;
|
||||
if (target.protocol === "file:") return target.host === expected.host && target.pathname === expected.pathname;
|
||||
return ["http:", "https:", "edgeever-app:"].includes(target.protocol)
|
||||
&& target.hostname === expected.hostname && target.port === expected.port;
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
export const isAllowedPrintPreviewUrl = (targetUrl, appUrl) => {
|
||||
try {
|
||||
const target = new URL(targetUrl);
|
||||
const currentApp = new URL(appUrl);
|
||||
if (target.username || target.password) return false;
|
||||
|
||||
if (currentApp.protocol === "file:") {
|
||||
const expected = pathToFileURL(join(dirname(fileURLToPath(currentApp)), "note-print.html"));
|
||||
|
||||
@@ -54,6 +54,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
|
||||
return () => ipcRenderer.removeListener("desktop:ai-direct-chunk", listener);
|
||||
},
|
||||
listAcpAdapters: () => ipcRenderer.invoke("desktop:acp-list"),
|
||||
chooseAcpAdapterPath: () => ipcRenderer.invoke("desktop:acp-choose-path", "antigravity"),
|
||||
probeAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-probe", input),
|
||||
installAcpAdapter: (id) => ipcRenderer.invoke("desktop:acp-install", id),
|
||||
authenticateAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-authenticate", input),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// Print windows deliberately expose no desktop bridge.
|
||||
@@ -27,6 +27,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"ai": "7.0.137",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
"dompurify": "3.4.16",
|
||||
"docx-preview": "0.4.1",
|
||||
"fabric": "7.4.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
FilePlus
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { marked } from "marked";
|
||||
import { safeMarkdownPreview } from "@/lib/safe-markdown-preview";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -148,12 +148,12 @@ export const TemplatesPane = ({
|
||||
};
|
||||
|
||||
const draftHtmlPreview = useMemo(
|
||||
() => (draft.contentMarkdown ? (marked.parse(draft.contentMarkdown) as string) : ""),
|
||||
() => (draft.contentMarkdown ? safeMarkdownPreview(draft.contentMarkdown) : ""),
|
||||
[draft.contentMarkdown]
|
||||
);
|
||||
|
||||
const previewHtmlContent = useMemo(
|
||||
() => (previewTemplate?.contentMarkdown ? (marked.parse(previewTemplate.contentMarkdown) as string) : ""),
|
||||
() => (previewTemplate?.contentMarkdown ? safeMarkdownPreview(previewTemplate.contentMarkdown) : ""),
|
||||
[previewTemplate?.contentMarkdown]
|
||||
);
|
||||
|
||||
|
||||
@@ -129,7 +129,10 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => void listDesktopAcpAdapters().then((adapters) => {
|
||||
if (!cancelled) setListed(adapters);
|
||||
if (!cancelled) {
|
||||
setListed(adapters);
|
||||
setAdapterPath(adapters.find((adapter) => adapter.id === "antigravity")?.customPath ?? "");
|
||||
}
|
||||
}).catch(() => {});
|
||||
refresh();
|
||||
const interval = window.setInterval(refresh, 5_000);
|
||||
@@ -311,7 +314,7 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => {
|
||||
</div>
|
||||
|
||||
{adapterId === "antigravity" ? (
|
||||
<label className="grid gap-1.5 text-xs font-normal leading-5 text-slate-700">
|
||||
<div className="grid gap-1.5 text-xs font-normal leading-5 text-slate-700">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{t("aiAssistant.agentSource.pathLabel")}
|
||||
<TooltipProvider>
|
||||
@@ -328,16 +331,21 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => {
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={adapterPath}
|
||||
aria-label={t("aiAssistant.agentSource.pathLabel")}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
placeholder={t("aiAssistant.agentSource.pathPlaceholder")}
|
||||
onChange={(event) => {
|
||||
setAdapterPath(event.target.value);
|
||||
setProbed(null);
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => {
|
||||
void window.edgeeverDesktop?.chooseAcpAdapterPath?.().then((selected) => {
|
||||
if (selected) { setAdapterPath(selected); setProbed(null); }
|
||||
}).catch(() => setInstallError(true));
|
||||
}}>
|
||||
{t("aiAssistant.agentSource.choosePath")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2 rounded-lg bg-muted/40 p-3">
|
||||
|
||||
@@ -916,9 +916,11 @@ export const setMemoDragPreview = (dataTransfer: DataTransfer, label: string) =>
|
||||
<svg style="width: 15px; height: 15px; color: var(--brand-green); flex-shrink: 0;" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"></path>
|
||||
</svg>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600;">${label}</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600;"></span>
|
||||
`;
|
||||
|
||||
dragImage.querySelector("span")!.textContent = label;
|
||||
|
||||
Object.assign(dragImage.style, {
|
||||
position: "fixed",
|
||||
top: "-9999px",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
// Render stored content as HTML, never as an active document. In particular,
|
||||
// templates must not introduce SVG, CSS, frames, or privileged scheme links.
|
||||
export const safeMarkdownPreview = (markdown: string) => DOMPurify.sanitize(
|
||||
marked.parse(markdown, { async: false }),
|
||||
{
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select"],
|
||||
FORBID_ATTR: ["style", "id", "name"],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^:]*$)/i,
|
||||
},
|
||||
);
|
||||
Vendored
+1
@@ -125,6 +125,7 @@ interface EdgeEverDesktopBridge {
|
||||
updateError?: string;
|
||||
authMethods?: Array<{ id: string; name: string }>;
|
||||
}>>;
|
||||
chooseAcpAdapterPath?(): Promise<string | null>;
|
||||
probeAcpAdapter?(input: { id: "codex" | "claudeCode" | "antigravity" | "openClaw" | "hermesAgent" | "grokBuild" | "deepseekHarness" | "piAgent" | "workbuddyCn" | "workbuddyIntl"; path?: string }): Promise<{
|
||||
id: "codex" | "claudeCode" | "antigravity" | "openClaw" | "hermesAgent" | "grokBuild" | "deepseekHarness" | "piAgent" | "workbuddyCn" | "workbuddyIntl";
|
||||
label: string;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html><meta charset="utf-8"><title>EdgeEver security regression</title><h1>Renderer boundary tests</h1><pre id="result">Running</pre><div id="preview"></div>
|
||||
<script type="module">
|
||||
import { safeMarkdownPreview } from '/src/lib/safe-markdown-preview.ts';
|
||||
import { setMemoDragPreview } from '/src/lib/app-helpers.ts';
|
||||
window.securityMarker = 0;
|
||||
const attack = '<img src=x onerror="window.securityMarker++"><svg onload="window.securityMarker++"></svg><iframe src="javascript:window.securityMarker++"></iframe><meta http-equiv="refresh" content="0;url=/evil"><a href="javascript:window.securityMarker++">bad</a><a href="edgeever-resource://resource/pwn">resource</a>';
|
||||
const preview = document.querySelector('#preview');
|
||||
preview.innerHTML = safeMarkdownPreview('# Safe heading\n\n**bold** [link](https://example.org)\n\n'+attack);
|
||||
const unsafe = preview.querySelectorAll('svg,iframe,meta,[onerror],[onload],[href^="javascript:"],[href^="edgeever-resource:"]').length;
|
||||
let drag;
|
||||
setMemoDragPreview({setDragImage: element => { drag = element.cloneNode(true); }}, '</span><img src=x onerror="window.securityMarker++">');
|
||||
setTimeout(() => {
|
||||
const results = {unsafeMarkup:unsafe, executedHandlers:window.securityMarker, heading:preview.querySelector('h1')?.textContent, bold:preview.querySelector('strong')?.textContent, dragText:drag.querySelector('span')?.textContent, dragInjectedImages:drag.querySelectorAll('img').length};
|
||||
document.querySelector('#result').textContent = JSON.stringify(results,null,2);
|
||||
document.body.dataset.passed = String(unsafe===0 && window.securityMarker===0 && results.heading==='Safe heading' && results.bold==='bold' && results.dragInjectedImages===0);
|
||||
},150);
|
||||
</script>
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@edgeever/desktop",
|
||||
"version": "1.111.0",
|
||||
"version": "1.112.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.5.1",
|
||||
"@edgeever/public-network": "workspace:*",
|
||||
@@ -250,6 +250,7 @@
|
||||
"ai": "7.0.137",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
"docx-preview": "0.4.1",
|
||||
"dompurify": "3.4.16",
|
||||
"fabric": "7.4.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
@@ -4385,8 +4386,6 @@
|
||||
|
||||
"@antfu/install-pkg/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"@antv/infographic/postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
|
||||
|
||||
"@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@astrojs/check/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -4835,8 +4834,6 @@
|
||||
|
||||
"log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
|
||||
|
||||
"magicast/source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
@@ -5187,8 +5184,6 @@
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"postcss/source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
@@ -5321,8 +5316,6 @@
|
||||
|
||||
"zip-dir/jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"@antv/infographic/postcss/nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"@astrojs/check/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"@astrojs/check/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
EdgeEver provides client plugin and no-code theme extension capabilities. Users can install extensions from the official marketplace, public GitHub repositories, or manifest URLs. Installed extensions sync across Web and desktop platforms with the current workspace and run while EdgeEver is open; Android and iOS native apps do not run plugins.
|
||||
|
||||
> [!NOTE]
|
||||
> **Security Model**: Client plugins use a trusted-code model (similar to Obsidian). Enabling a plugin grants it trust to run within the client JavaScript environment and invoke plugin APIs. Capability declarations (`permissions`) in the manifest disclose the functional scope to users and do not enforce a hard sandbox. Theme packages are purely declarative JSON and execute no JavaScript.
|
||||
> **Security Model**: Client plugins use a trusted-code model (similar to Obsidian). Enabling a plugin grants it trust to run within the client JavaScript environment and invoke plugin APIs. Capability declarations (`permissions`) in the manifest disclose the functional scope to users and do not enforce a hard sandbox. The same applies to `networkHosts`: it describes intended destinations rather than restricting plugin code. Desktop plugins can also reach login credentials, local data and Agent capabilities through the client bridge. Theme packages are purely declarative JSON and execute no JavaScript.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
EdgeEver 提供客户端插件与无代码主题扩展能力。用户可从官方市场、公开 GitHub 仓库或 Manifest 地址安装扩展。已安装扩展随当前工作区在 Web 与桌面端之间同步,在应用打开期间运行;Android 与 iOS 原生端暂不运行插件。
|
||||
|
||||
> [!NOTE]
|
||||
> **安全模型**:客户端插件采用受信任代码模型(类似 Obsidian)。启用插件即代表信任其在客户端 JavaScript 环境中运行并调用插件 API。清单中的能力声明(Permissions)用于向用户披露插件涉及的功能范围,不构成强制安全沙箱。主题包为纯声明式 JSON,不执行任何 JavaScript。
|
||||
> **安全模型**:客户端插件采用受信任代码模型(类似 Obsidian)。启用插件即代表信任其在客户端 JavaScript 环境中运行并调用插件 API。清单中的能力声明(Permissions)用于向用户披露插件涉及的功能范围,不构成强制安全沙箱。主题包为纯声明式 JSON,不执行任何 JavaScript。 `networkHosts` 同样只是用途声明,不限制插件代码的网络访问。桌面插件还可以通过客户端桥访问登录凭据、本地数据及 Agent 能力。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@
|
||||
"publish:stores": "bun scripts/store-delivery.mjs",
|
||||
"publish:extension": "bun scripts/publish-web-clipper.mjs",
|
||||
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
|
||||
"test:desktop": "bun test apps/desktop/src/main/global-shortcut.test.mjs apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/update-diagnostics.test.mjs apps/desktop/src/main/update-progress.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/windows-share-menu.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/picgo.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/desktop/src/main/windows-taskbar-diagnostics.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-acp.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/picgo.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/shared-file-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun test --cwd apps/desktop src/main/acp-adapter-manager.test.mjs src/main/acp-host.test.mjs src/main/workbuddy-auth.test.mjs src/main/antigravity-proxy.test.mjs src/main/acp-mcp-bridge.test.mjs && bun run test:desktop:sidecar",
|
||||
"test:desktop": "bun test apps/desktop/src/main/global-shortcut.test.mjs apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/update-diagnostics.test.mjs apps/desktop/src/main/update-progress.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/wechat-chat-archive.test.mjs apps/desktop/src/main/wechat-share-import.test.mjs apps/desktop/src/main/share-extension-registration.test.mjs apps/desktop/src/main/windows-share-menu.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/ai-direct.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/picgo.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-hibernate.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/security-boundaries.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/screenshot-capture.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/staged-resource-alias.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/desktop/src/main/windows-taskbar-diagnostics.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-workspace-restore.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-acp.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/picgo.test.mjs apps/web/src/lib/screenshot-import.test.mjs apps/web/src/lib/wechat-chat-import.test.mjs apps/web/src/lib/shared-file-import.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun test --cwd apps/desktop src/main/acp-adapter-manager.test.mjs src/main/acp-host.test.mjs src/main/workbuddy-auth.test.mjs src/main/antigravity-proxy.test.mjs src/main/acp-mcp-bridge.test.mjs && bun run test:desktop:sidecar",
|
||||
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
|
||||
"verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration",
|
||||
"verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs",
|
||||
|
||||
@@ -38,6 +38,6 @@ export async function nodePublicFetch(input, init = {}) {
|
||||
});
|
||||
resolve(new Response(body, { status, statusText: incoming.statusMessage, headers: responseHeaders }));
|
||||
});
|
||||
req.on("error", reject); req.end();
|
||||
req.on("error", reject); req.end(init.body ?? undefined);
|
||||
});
|
||||
}
|
||||
@@ -1187,7 +1187,7 @@ export const enUS = {
|
||||
},
|
||||
trustWarning: {
|
||||
title: "Turn on community plugins?",
|
||||
description: "Community plugins run third-party code on your behalf and inherit EdgeEver's access, including the ability to read and modify notes and connect to the internet. Enable only plugins you trust.",
|
||||
description: "Plugins run with full client access. Enable only plugins you trust.",
|
||||
confirm: "Turn on community plugins",
|
||||
},
|
||||
toggle: "Enable or disable {{name}}",
|
||||
@@ -1733,7 +1733,8 @@ export const enUS = {
|
||||
piAgent: "pi agent",
|
||||
pathLabel: "Antigravity executable",
|
||||
pathPlaceholder: "/absolute/path/to/antigravity-acp",
|
||||
pathHint: "Enter the absolute path of an existing Antigravity ACP executable, or let EdgeEver install one on demand.",
|
||||
choosePath: "Choose executable",
|
||||
pathHint: "Choose an existing Antigravity ACP executable, or install the connector automatically.",
|
||||
install: "Install connector",
|
||||
installHint: "When a local agent is detected, its connector downloads from the official source. This may exceed 100 MiB; updates are checked automatically.",
|
||||
claudeCodeNotFound: "Claude Code ACP connector not found",
|
||||
|
||||
@@ -1176,7 +1176,7 @@ export const ja = {
|
||||
},
|
||||
trustWarning: {
|
||||
title: "コミュニティプラグインをオンにしますか?",
|
||||
description: "コミュニティプラグインはあなたの代わりにサードパーティのコードを実行し、EdgeEver のアクセス権を引き継ぎます。ノートの読み取りと変更、インターネット接続も含まれます。信頼できるプラグインだけを有効にしてください。",
|
||||
description: "プラグインはクライアントの全権限で動作します。信頼するものだけを有効にしてください。",
|
||||
confirm: "コミュニティプラグインをオンにする",
|
||||
},
|
||||
toggle: "{{name}} を有効または無効にする",
|
||||
@@ -1722,7 +1722,8 @@ export const ja = {
|
||||
piAgent: "pi agent",
|
||||
pathLabel: "Antigravity の実行ファイル",
|
||||
pathPlaceholder: "/absolute/path/to/antigravity-acp",
|
||||
pathHint: "既存の Antigravity ACP 実行ファイルの絶対パスを入力するか、EdgeEver で必要時にインストールします。",
|
||||
choosePath: "実行ファイルを選択",
|
||||
pathHint: "既存の Antigravity ACP 実行ファイルを選ぶか、接続コンポーネントを自動インストールします。",
|
||||
install: "接続コンポーネントをインストール",
|
||||
installHint: "ローカルのエージェントを検出すると、公式配布元からダウンロードします。100 MiB を超える場合があり、インストール後は更新を自動確認します。",
|
||||
claudeCodeNotFound: "Claude Code ACP 接続コンポーネントが見つかりません",
|
||||
|
||||
@@ -1176,7 +1176,7 @@ export const pl = {
|
||||
},
|
||||
trustWarning: {
|
||||
title: "Włączyć wtyczki społeczności?",
|
||||
description: "Wtyczki społeczności uruchamiają w Twoim imieniu kod innych firm i dziedziczą uprawnienia EdgeEver, w tym możliwość odczytu i modyfikowania notatek oraz łączenia z internetem. Włączaj tylko wtyczki, którym ufasz.",
|
||||
description: "Wtyczki mają pełny dostęp do klienta. Włączaj tylko zaufane wtyczki.",
|
||||
confirm: "Włącz wtyczki społeczności",
|
||||
},
|
||||
toggle: "Włącz lub wyłącz {{name}}",
|
||||
@@ -1722,7 +1722,8 @@ export const pl = {
|
||||
piAgent: "pi agent",
|
||||
pathLabel: "Plik wykonywalny Antigravity",
|
||||
pathPlaceholder: "/absolute/path/to/antigravity-acp",
|
||||
pathHint: "Wpisz bezwzględną ścieżkę istniejącego pliku wykonywalnego Antigravity ACP lub pozwól EdgeEver zainstalować go w razie potrzeby.",
|
||||
choosePath: "Wybierz program",
|
||||
pathHint: "Wybierz istniejący program Antigravity ACP lub zainstaluj konektor automatycznie.",
|
||||
install: "Zainstaluj konektor",
|
||||
installHint: "Po wykryciu lokalnego agenta jego konektor jest pobierany z oficjalnego źródła. Może zajmować ponad 100 MiB; aktualizacje są sprawdzane automatycznie.",
|
||||
claudeCodeNotFound: "Nie znaleziono konektora Claude Code ACP",
|
||||
|
||||
@@ -1187,7 +1187,7 @@ export const zhCN = {
|
||||
},
|
||||
trustWarning: {
|
||||
title: "开启社区插件?",
|
||||
description: "社区插件会代表你运行第三方代码,并继承 EdgeEver 的访问能力,包括读取和修改笔记、连接互联网。请仅启用你信任的插件。",
|
||||
description: "插件以完整客户端权限运行,请仅启用你信任的插件。",
|
||||
confirm: "开启社区插件",
|
||||
},
|
||||
toggle: "启用或停用 {{name}}",
|
||||
@@ -1733,7 +1733,8 @@ export const zhCN = {
|
||||
piAgent: "pi agent",
|
||||
pathLabel: "Antigravity 可执行文件",
|
||||
pathPlaceholder: "/absolute/path/to/antigravity-acp",
|
||||
pathHint: "如需使用已有的 Antigravity ACP 程序,可填写其绝对路径;也可以让 EdgeEver 按需安装。",
|
||||
choosePath: "选择程序",
|
||||
pathHint: "可选择已有的 Antigravity ACP 程序,或自动安装连接组件。",
|
||||
install: "安装连接组件",
|
||||
installHint: "检测到本机 Agent 后会从官方来源下载,可能超过 100 MiB;安装后会自动检查更新。",
|
||||
claudeCodeNotFound: "未找到 Claude Code ACP 连接组件",
|
||||
|
||||
Reference in new issue
Block a user