Skip to content

Commit e8983d9

Browse files
authored
fix(desktop): trust bundled ApeMind workflows by title+description fallback (#22)
* fix(desktop): trust bundled recipes by title when runtime hash mismatches PR #20 pre-computed SHA-256 over `JSON.stringify(yaml.parse(file))` and wrote `userData/recipe_hashes/<hash>.hash` for each bundled YAML. Field inspection on 3F (冯诺伊曼) confirms three `.hash` files exist for the three bundled recipes, but earayu2 still sees "⚠️ 新配方警告" when opening any of them. Root cause: the `recipe` object passed to the `has-accepted-recipe-before` IPC at runtime comes from goose-server (Rust). Its `JSON.stringify` shape (field order, `null` for absent optional fields, extra metadata) differs from a naive `JSON.stringify(yaml.parse(file))`, so the SHA-256 hashes do not match and the existing pre-trusted file is never found. Fix: add a second match path that does not depend on byte-exact JSON serialization: 1. `seedDefaultRecipes` also writes `userData/bundled-recipe-titles.json` — an array of the `title` fields parsed from each bundled YAML. 2. `recipeHash.ts` `has-accepted-recipe-before` handler: when the hash lookup misses with ENOENT, fall back to checking whether `recipe.title` is in the bundled-titles list. If yes, return true. Effect: bundled recipes are trusted on first open even if the runtime recipe shape differs from yaml.parse output. Existing hash-based trust still works for user-recorded acceptances (e.g. user clicks "Trust and Execute" on a non-bundled recipe). 5-cat compliance: still category 3 (默认配置) + category 4 (UI behavior). The IPC handler change is localized to one file; no change to goose-rs / RecipeWarningModal / recipe loading logic. Signed-off-by: earayu <earayu@163.com> * refine: match bundled recipe by title AND description to lower collision risk @梅西 raised that pure title match could false-positive on user-imported recipes with the same title. Tighten by requiring both `title` and `description` to match the bundled entry. Both fields are stable identifiers of bundled ApeMind workflows (authored by 梅西, unlikely to collide with random user imports). User edits that change description still see the warning, which is the intended security behavior for modified recipes. Signed-off-by: earayu <earayu@163.com> --------- Signed-off-by: earayu <earayu@163.com>
1 parent 90356e4 commit e8983d9

2 files changed

Lines changed: 42 additions & 1 deletion

File tree

ui/desktop/src/main.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,14 @@ async function seedDefaultRecipes(): Promise<void> {
181181
: path.join(__dirname, '..', 'default-recipes');
182182
const destDir = path.join(os.homedir(), '.config', 'goose', 'recipes');
183183
const hashesDir = path.join(app.getPath('userData'), 'recipe_hashes');
184+
const bundledTitlesFile = path.join(app.getPath('userData'), 'bundled-recipe-titles.json');
184185

185186
if (!fsSync.existsSync(sourceRoot)) return;
186187

187188
await fs.mkdir(destDir, { recursive: true });
188189
await fs.mkdir(hashesDir, { recursive: true });
189190
const entries = await fs.readdir(sourceRoot);
191+
const bundledTitles: Array<{ title: string; description: string }> = [];
190192
for (const entry of entries) {
191193
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
192194
const sourcePath = path.join(sourceRoot, entry);
@@ -200,6 +202,14 @@ async function seedDefaultRecipes(): Promise<void> {
200202
try {
201203
const yamlContent = await fs.readFile(sourcePath, 'utf-8');
202204
const parsed = yaml.parse(yamlContent);
205+
if (
206+
parsed &&
207+
typeof parsed === 'object' &&
208+
typeof parsed.title === 'string' &&
209+
typeof parsed.description === 'string'
210+
) {
211+
bundledTitles.push({ title: parsed.title, description: parsed.description });
212+
}
203213
const hash = crypto.createHash('sha256').update(JSON.stringify(parsed)).digest('hex');
204214
const hashFile = path.join(hashesDir, `${hash}.hash`);
205215
if (!fsSync.existsSync(hashFile)) {
@@ -210,6 +220,13 @@ async function seedDefaultRecipes(): Promise<void> {
210220
log.warn(`[seedDefaultRecipes] failed to pre-trust ${entry}:`, err);
211221
}
212222
}
223+
224+
try {
225+
await fs.writeFile(bundledTitlesFile, JSON.stringify(bundledTitles, null, 2));
226+
log.info(`[seedDefaultRecipes] wrote bundled-recipe-titles.json with ${bundledTitles.length} titles`);
227+
} catch (err) {
228+
log.warn(`[seedDefaultRecipes] failed to write bundled-recipe-titles.json:`, err);
229+
}
213230
}
214231

215232
function getSettings(): Settings {

ui/desktop/src/utils/recipeHash.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ipcMain, app, BrowserWindow } from 'electron';
22
import fs from 'node:fs/promises';
3+
import fsSync from 'node:fs';
34
import path from 'node:path';
45
import crypto from 'crypto';
56

@@ -16,6 +17,29 @@ async function getRecipeHashesDir(): Promise<string> {
1617
return hashesDir;
1718
}
1819

20+
function isBundledRecipeByTitleAndDescription(recipe: unknown): boolean {
21+
if (typeof recipe !== 'object' || recipe === null) return false;
22+
const title = (recipe as { title?: unknown }).title;
23+
const description = (recipe as { description?: unknown }).description;
24+
if (typeof title !== 'string' || typeof description !== 'string') return false;
25+
try {
26+
const listFile = path.join(app.getPath('userData'), 'bundled-recipe-titles.json');
27+
if (!fsSync.existsSync(listFile)) return false;
28+
const raw = fsSync.readFileSync(listFile, 'utf-8');
29+
const items: unknown = JSON.parse(raw);
30+
if (!Array.isArray(items)) return false;
31+
return items.some(
32+
(it) =>
33+
it != null &&
34+
typeof it === 'object' &&
35+
(it as { title?: unknown }).title === title &&
36+
(it as { description?: unknown }).description === description
37+
);
38+
} catch {
39+
return false;
40+
}
41+
}
42+
1943
ipcMain.handle('has-accepted-recipe-before', async (_event, recipe) => {
2044
const hash = calculateRecipeHash(recipe);
2145
const hashFile = path.join(await getRecipeHashesDir(), `${hash}.hash`);
@@ -24,7 +48,7 @@ ipcMain.handle('has-accepted-recipe-before', async (_event, recipe) => {
2448
return true;
2549
} catch (err) {
2650
if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') {
27-
return false;
51+
return isBundledRecipeByTitleAndDescription(recipe);
2852
}
2953
throw err;
3054
}

0 commit comments

Comments
 (0)