feat(desktop): editing, the sources screen, the checks and the history (3.5, 6.2-6.7, 7.6, 8.6-8.9, 8.11) - #14
Conversation
Plan 3.5, 6.2 to 6.7, 7.6, 8.6 to 8.9 and 8.11. Editing goes through `gateWrite` — the same entrance the agent's writes use. Group 5's whole claim is one implementation with three callers, and an editor that validated its own way would be a fourth, quietly disagreeing with the hook about what a well-formed page is. The store's denial reasons are shown verbatim, because 9.13 says the message has three mouths and they have to say the same thing. 8.8's refusal checks staleness *before* the gate: the two have different answers, one being "look at what changed" and the other "fix this field". And it is `isStoreOnlyChange` rather than a string comparison, because a correction the store itself made is not somebody else's edit (5.8) — the editor also takes back what *landed* rather than what it sent, or its next save would look stale against a change it did not make. When it refuses, it shows both versions and merges neither: at that moment both still exist, and the only unrecoverable outcome is picking one silently. A rename repoints what pointed at the page and is one operation, so undo puts the rename and every repointed link back together; the page's `id` follows its slug keeping its type, or 5.1 would refuse every later save of it. A delete deliberately does *not* rewrite the links — they are the record that something was expected to be there, and 7.1 reports them. A rename knows where the reader should go instead; a delete does not. The write path is a separate module from the read path on purpose. `api.ts` imports `@open-wiki/access/read` and cannot write whatever it is asked to; `edit.ts` imports the barrel and can. Keeping them apart makes "the read surface is read-only" a fact about the import graph rather than a promise. The sources screen derives every row from the directory, which 6.1 already settled as the only record — one read of the wiki feeds all of them, so twenty sources are not twenty walks over the pages. It flags a source nobody cites, which is the case that disappears from view on its own. A title is correctable without moving the directory or touching a citation, which is what makes `adr:0011`'s freeze bearable. 8.6 seeks the audio to the instant rather than starting it at zero — that difference is the entire task — and a citation the recording does not contain says so instead, which is the same answer 5.4 gives when it refuses one. The original of an uploaded source is found by deriving `source.<ext>` from the id rather than reading a filename out of the manifest: the manifest is a file in a project directory that arrives with a clone. 3.5 reports what was recognised *and* what was not, because a partial success reported as success is how a source silently never arrives. 7.6 is rendering rather than new checking, exactly as the deferral said: every finding already carries its `fix`. 8.11 is honest in the panel itself about covering only what was observed — a page written through a harness with no hooks is not in the log, and a history presenting itself as complete would make "undo" silently mean "undo some of it". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
**None of it worked.** `preload.ts` never gained the twelve new channels — the edit was lost in a rebase — so every new feature typechecked, tested green, and threw "not a function" the first time anyone clicked. Nothing caught it because `bridge.ts` *asserts* the shape of `window.ow` rather than deriving it. The preload now carries them, and a type-level parity check at the bottom of that file makes the same gap a compile error rather than a runtime one. Two holes with teeth, both in the write path: - **`rename` could overwrite `CLAUDE.md` and `.claude/rules/*`.** It built a path from a renderer-supplied name and was the one write in the module that never called `gateWrite` — so `isConfigWrite`, the guard 9.6 exists for, was never consulted. `../CLAUDE` was enough, from the rename prompt, by hand. That is instruction injection into the agent that has tool access to this machine. - **`create`'s slug escaped `wiki/`.** `gateWrite` classifies by where a write lands and has no opinion about anything outside the wiki, so a traversal slug both escaped *and* skipped every check the gate exists to apply. Both now validate the name against the store's own slug shape before a path is built from it, and the rename goes through the gate like every other write — which also catches what `renameId` exists to prevent, arriving through the front door: a name that is not a slug produces an `id` the schema refuses, and every later save of that page would fail forever. `undo` is the other one. It is renderer-reachable now, and it reads `.state/log.jsonl` — a file in the project directory, which arrives with a clone. `op.snapshotId` was joined onto a path unchecked, so a crafted `../..` made `assertWithin(snapDir, …)` confine against a root the attacker chose and copied files from *outside* the project in; and a page path of `.git/config` with `existed: false` was a delete. The id must now be a UUID and the paths must be under `wiki/` or `raw/`. Data loss, on the platform this product targets: renaming a page to a case variant deleted it. Windows is case-insensitive, `realpathSync` canonicalises `wiki/Fenix.md` back to `wiki\fenix.md`, so the write landed on the source and the `rmSync` after it removed the page. "Rename to fix the capitalisation" is an ordinary thing to do. `renameId`'s regex crossed the closing `---`: on a page with no `id` in its frontmatter it rewrote the first `id:` in the *prose* and swallowed the rest of that line, and it was quadratic — a megabyte page froze the main process for half a minute, and page content is what arrives with a clone. It now splits the block off and works only inside it. A rename left a broken link in `wiki/index.md` and undo of a create left a dangling entry there, both because `listPages` deliberately excludes the non-entity pages and `registerInIndex` writes one of them. The rename repoints them too; the create's snapshot covers the index. The 8.8 conflict button said "use the version on disk as my base" and armed the user's own buffer to win — the silent loss with a reassuring label on it. Two buttons now, each doing what it says. And 3.5's drop never produced a path: `File.path` was removed in Electron 32 and this app pins 38, so the list was always empty and the handler returned silently. The preload supplies it via `webUtils.getPathForFile`, and a drop that yields nothing now says so. 6.3's box came back. The per-chunk progress is on the sources screen because `sourceState` derives it for free, but there is no transcribe button and no retry-what-failed — it needs 8.3's credential, and a ticked box is the only record the next session gets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
📝 WalkthroughWalkthroughThe desktop application adds page editing and lifecycle operations, source inspection, citation navigation, integrity findings, file-drop ingestion, operation history, undo, and typed IPC and renderer support. ChangesDesktop editing and undo
Source ingestion and citation
IPC and renderer
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant Renderer
participant Preload
participant MainIPC
participant EditModule
participant FileSystem
User->>Renderer: Edit and save Markdown
Renderer->>Preload: Send slug, content, and base content
Preload->>MainIPC: Invoke save channel
MainIPC->>EditModule: Call savePage
EditModule->>FileSystem: Validate, snapshot, and write page
FileSystem-->>EditModule: Return persisted content and operation
EditModule-->>MainIPC: Return save result
MainIPC-->>Preload: Return save result
Preload-->>Renderer: Update editor or show conflict
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 14
🧹 Nitpick comments (8)
apps/desktop/src/renderer/tokens.css (1)
405-410: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider a single-column fallback for the editor panes.
.editor__panesis always two equal columns. Plan 8.2 describes a window read beside the harness, so a narrow width is a normal case, and at that width both the source and the preview become too narrow to use. The citation panel at line 473 already guards its width withmin().🎨 Proposed change
.editor__panes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); min-height: 0; } + +@media (max-width: 720px) { + .editor__panes { + grid-template-columns: 1fr; + } +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/src/renderer/tokens.css` around lines 405 - 410, Update the .editor__panes grid to provide a single-column fallback at narrow widths, while preserving the two-column layout when sufficient space is available. Use a responsive grid definition or media query consistent with the existing citation panel’s min() width handling.packages/audio/src/index.ts (1)
10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a dedicated
@open-wiki/audio/atomicexport.Add the subpath to
packages/audio/package.jsonand importwriteAtomicfrom it. The barrel also loadsffmpeg.js, which importsnode:child_process.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/audio/src/index.ts` at line 10, Add an "./atomic" subpath export in packages/audio/package.json pointing to the atomic module, and update the package entry point to import writeAtomic from that dedicated subpath instead of re-exporting through the barrel. Keep the atomic export isolated so consumers do not load ffmpeg.js or node:child_process.apps/desktop/src/renderer/App.tsx (1)
335-341: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse a key that stays unique across a drop.
outcome.nameis a basename. A drop of two files with the same name from different folders produces duplicate keys, which makes React warn and reconcile the wrong list item. Use the index together with the name.♻️ Proposed change
- {outcomes.map((outcome) => ( - <li key={outcome.name}> + {outcomes.map((outcome, i) => ( + <li key={`${outcome.name}-${i}`}>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/src/renderer/App.tsx` around lines 335 - 341, Update the outcomes.map rendering in App.tsx to construct each list item's key from both outcome.name and the map index, ensuring duplicate basenames from different files remain unique while preserving the existing displayed content.apps/desktop/src/renderer/Panels.tsx (1)
133-140: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueAn older
locateresponse can overwrite a newer one.The effect has no cleanup. If
idorfragmentchanges while a request is in flight, the earlier promise can resolve last and set a location for the previous citation. Ignore the result after the effect is cleaned up.♻️ Proposed change
useEffect(() => { + let live = true; void bridge() .locate(id, fragment) - .then(setAt) - .catch((e: unknown) => - setAt({ kind: "missing", reason: e instanceof Error ? e.message : String(e) }), - ); + .then((next) => { + if (live) setAt(next); + }) + .catch((e: unknown) => { + if (live) setAt({ kind: "missing", reason: e instanceof Error ? e.message : String(e) }); + }); + return () => { + live = false; + }; }, [id, fragment]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/src/renderer/Panels.tsx` around lines 133 - 140, Update the useEffect containing bridge().locate so it tracks whether the current effect is still active and ignores both successful and failed results after cleanup. Set the location only while the request corresponds to the current id and fragment, and return an effect cleanup that marks the request inactive.apps/desktop/src/renderer/Editor.tsx (1)
104-112: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueState why the preview may use
dangerouslySetInnerHTML.
App.tsxcarries a comment that explains the rendered HTML comes fromrenderPageBodywithhtml: falseand a closed set of tags. The same reasoning applies here, and a static analysis rule flags this line. Add the same note so a later reader does not have to rediscover it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/src/renderer/Editor.tsx` around lines 104 - 112, Add a concise explanatory comment immediately before the dangerouslySetInnerHTML usage in the preview article, matching the rationale documented in App.tsx: preview HTML is produced by renderPageBody with html: false and a closed set of allowed tags. Do not change the rendering behavior.Source: Linters/SAST tools
apps/desktop/tests/edit.spec.ts (2)
92-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the refusal reason before you read fields from it.
The
ifguard makes the assertion optional. Ifreasonwere"stale", the block is skipped and the test still passes. The same pattern appears at Lines 182-185.♻️ Proposed change
expect(result.saved).toBe(false); - if (result.saved === false && result.reason === "invalid") { - expect(result.problems.join(" ")).toContain("ghost"); - } + expect(result.saved === false && result.reason).toBe("invalid"); + if (result.saved === false && result.reason === "invalid") { + expect(result.problems.join(" ")).toContain("ghost"); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/tests/edit.spec.ts` around lines 92 - 96, Update the assertions around the saved-result checks in the test cases near the existing guard and the matching case near lines 182-185 to assert that result.reason is exactly "invalid" before accessing result.problems. Remove the conditional guard so an unexpected refusal reason fails the test rather than skipping the problem-content assertion.
443-450: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueWall-clock budget can make this test flaky.
The 1000 ms budget depends on the machine and on CI load. The regression it guards is quadratic scanning, so a generous budget or a larger input keeps the signal without the flake risk. Consider raising the budget, or asserting the output instead of the duration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/tests/edit.spec.ts` around lines 443 - 450, Update the “is fast on a long page” test around renameId to avoid a machine-dependent 1000 ms wall-clock assertion: either use a substantially more generous threshold with the existing large input or assert the renamed output while retaining a sufficiently large input to catch quadratic scanning.apps/desktop/src/main/sources.ts (1)
137-149: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winA malformed
timemap.jsonproduces a wrong refusal message.
readTimeMapcasts the parsed JSON toTimeMapwithout checking its shape. IfcompressedDurationNsis missing,inRangecompares againstundefined, returnsfalse, andlocateCitationreports "past the end of this recording" for a citation that is in range. Treat an unusable time map like an absent one.♻️ Proposed change
function readTimeMap(dir: string): TimeMap | null { try { const file = join(dir, "timemap.json"); if (!existsSync(file)) return null; - return JSON.parse(readFileSync(file, "utf8")) as TimeMap; + const map = JSON.parse(readFileSync(file, "utf8")) as Partial<TimeMap>; + return typeof map.compressedDurationNs === "number" ? (map as TimeMap) : null; } catch { return null; } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/src/main/sources.ts` around lines 137 - 149, Update readTimeMap to validate the parsed JSON has a usable numeric compressedDurationNs before returning it; return null for missing, non-numeric, or otherwise invalid values so callers treat malformed timemap.json as absent. Keep inRange unchanged and preserve the existing valid TimeMap path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/desktop/src/main/edit.ts`:
- Around line 306-335: Include INDEX_PAGE in the touched paths used by
renamePage before calling snapshotOf, while avoiding duplicate entries when it
is already present from link rewriting. Preserve the existing rewrite tracking
and ensure the snapshot captures wiki/index.md unconditionally before
registerInIndex updates it.
In `@apps/desktop/src/main/ingest.ts`:
- Around line 47-58: Update ingestFile and the CHANNELS.drop flow to validate
renderer-supplied paths against the files recorded for the active drop,
rejecting any unapproved source before ingestion. Enforce the 64 MiB source-size
limit using file metadata before readFile, while preserving the existing
recognized-extension checks and DropOutcome error handling.
In `@apps/desktop/src/main/ipc.ts`:
- Around line 239-242: Update the CHANNELS.save and CHANNELS.create branches in
dispatch to validate renderer payloads instead of casting them directly. Add
dedicated coercion helpers near dispatch that normalize object fields to
strings, then construct SaveInput with slug, markdown, and baseMarkdown and
CreateInput with slug and markdown before calling api.save or api.create.
In `@apps/desktop/src/main/preload.ts`:
- Around line 77-82: Replace the incomplete _parity cast with a Record<keyof
OwBridge, unknown> assignment directly from api, so TypeScript requires api to
provide every OwBridge key while preserving the compile-time parity check.
In `@apps/desktop/src/renderer/App.tsx`:
- Around line 419-424: Update the rename success handling in App’s rename flow
so repointed-link notices do not call onError or render in the error state.
Route the message from result.repointed through the existing non-error notice
mechanism, such as the Dropped-style notice area, while preserving the visit to
the renamed wiki slug.
- Around line 176-182: Update the drop-processing logic around pathForFile so
unresolved files remain represented in the drop report instead of being removed
by filter(Boolean). Preserve resolved paths for normal processing, and add an
explicit unsuccessful outcome for each file whose path cannot be resolved so the
final counts and messages include every dropped file.
In `@apps/desktop/src/renderer/Editor.tsx`:
- Around line 72-74: Update the Cancel button’s onCancel flow in Editor so it
compares the current markdown with base and prompts for confirmation when they
differ before discarding edits; preserve the existing immediate cancellation
behavior when there are no changes or the user confirms.
In `@apps/desktop/src/renderer/Panels.tsx`:
- Around line 22-30: Update the findings-loading effect in Panels.tsx so IPC or
checkProject failures remain distinct from a successful empty findings result;
do not call setFindings([]) from the catch. Add or reuse an explicit error state
and render an error message for failures, while preserving “Nothing to fix.”
only when findings resolves to an empty array.
- Around line 174-177: Update fileUrl to encode each path segment before
constructing the file URL, preventing characters such as #, ?, and % from
altering URL parsing. Preserve Windows drive-letter syntax and the existing
slash normalization while ensuring the resulting URL still points to the
intended local file.
In `@apps/desktop/src/renderer/Sources.tsx`:
- Around line 93-103: Replace the citation `<a>` elements in the
`row.citedBy.map` rendering with keyboard-operable buttons that call
`onOpenPage(slug)`, and apply the `linkish` class to preserve link-like styling.
Add the matching `.linkish` rule to the renderer tokens stylesheet, including
visible focus styling consistent with existing focus behavior.
- Around line 66-76: Update the retitle callback in Sources so failures from
bridge().retitle are caught and reported through the existing error state. Pass
the error reporter through the relevant component/call site so a rejected
retitle reaches the screen, while preserving the finally block that clears busy
and the current cancellation behavior.
In `@apps/desktop/src/renderer/tokens.css`:
- Around line 468-480: Update the .source-at style rule to bound its height
relative to the viewport, using the existing spacing variables, and enable
internal scrolling for content that exceeds that limit. Preserve its fixed
bottom-right positioning and current width behavior.
In `@packages/access/src/write/undo.ts`:
- Around line 54-62: Update the undo loop around assertWithin and isUndoable so
the undoability check uses the confined destination, not the raw page.path:
derive the project-relative path from live using the appropriate path utility,
then pass that normalized relative path to isUndoable before restoring or
removing. Import the required relative-path helper and resolveReal alongside
assertWithin, preserving the existing containment validation and corruption
error behavior.
In `@plans/open-wiki.md`:
- Around line 243-245: Remove the stale “Deferred to group 8” note beneath
checklist item 7.6 in plans/open-wiki.md, preserving the completed 7.6 entry and
its single explanatory note without changing other plan content.
---
Nitpick comments:
In `@apps/desktop/src/main/sources.ts`:
- Around line 137-149: Update readTimeMap to validate the parsed JSON has a
usable numeric compressedDurationNs before returning it; return null for
missing, non-numeric, or otherwise invalid values so callers treat malformed
timemap.json as absent. Keep inRange unchanged and preserve the existing valid
TimeMap path.
In `@apps/desktop/src/renderer/App.tsx`:
- Around line 335-341: Update the outcomes.map rendering in App.tsx to construct
each list item's key from both outcome.name and the map index, ensuring
duplicate basenames from different files remain unique while preserving the
existing displayed content.
In `@apps/desktop/src/renderer/Editor.tsx`:
- Around line 104-112: Add a concise explanatory comment immediately before the
dangerouslySetInnerHTML usage in the preview article, matching the rationale
documented in App.tsx: preview HTML is produced by renderPageBody with html:
false and a closed set of allowed tags. Do not change the rendering behavior.
In `@apps/desktop/src/renderer/Panels.tsx`:
- Around line 133-140: Update the useEffect containing bridge().locate so it
tracks whether the current effect is still active and ignores both successful
and failed results after cleanup. Set the location only while the request
corresponds to the current id and fragment, and return an effect cleanup that
marks the request inactive.
In `@apps/desktop/src/renderer/tokens.css`:
- Around line 405-410: Update the .editor__panes grid to provide a single-column
fallback at narrow widths, while preserving the two-column layout when
sufficient space is available. Use a responsive grid definition or media query
consistent with the existing citation panel’s min() width handling.
In `@apps/desktop/tests/edit.spec.ts`:
- Around line 92-96: Update the assertions around the saved-result checks in the
test cases near the existing guard and the matching case near lines 182-185 to
assert that result.reason is exactly "invalid" before accessing result.problems.
Remove the conditional guard so an unexpected refusal reason fails the test
rather than skipping the problem-content assertion.
- Around line 443-450: Update the “is fast on a long page” test around renameId
to avoid a machine-dependent 1000 ms wall-clock assertion: either use a
substantially more generous threshold with the existing large input or assert
the renamed output while retaining a sufficiently large input to catch quadratic
scanning.
In `@packages/audio/src/index.ts`:
- Line 10: Add an "./atomic" subpath export in packages/audio/package.json
pointing to the atomic module, and update the package entry point to import
writeAtomic from that dedicated subpath instead of re-exporting through the
barrel. Keep the atomic export isolated so consumers do not load ffmpeg.js or
node:child_process.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ed55316d-65f1-49c1-872c-ee35c509ebc5
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (20)
apps/desktop/package.jsonapps/desktop/src/main/edit.tsapps/desktop/src/main/ingest.tsapps/desktop/src/main/ipc.tsapps/desktop/src/main/preload.tsapps/desktop/src/main/sources.tsapps/desktop/src/renderer/App.tsxapps/desktop/src/renderer/Editor.tsxapps/desktop/src/renderer/Panels.tsxapps/desktop/src/renderer/Sources.tsxapps/desktop/src/renderer/bridge.tsapps/desktop/src/renderer/index.htmlapps/desktop/src/renderer/navigation.tsapps/desktop/src/renderer/tokens.cssapps/desktop/tests/edit.spec.tsapps/desktop/tests/sources.spec.tspackages/access/src/index.tspackages/access/src/write/undo.tspackages/audio/src/index.tsplans/open-wiki.md
| const touched = [ref.path, target]; | ||
| const repointed: string[] = []; | ||
| const rewrites: Array<{ path: string; content: string }> = []; | ||
| // `listPages` deliberately excludes `index.md`, `changelog.md` and `log.md` | ||
| // — they are not entity pages. They do carry wikilinks, and `index.md` is | ||
| // guaranteed to link to this page because `registerInIndex` put it there, so | ||
| // a repoint that skipped them left a broken link in the one file certain to | ||
| // have one. | ||
| // `NON_ENTITY_PAGES` are bare filenames; they live at the top of `wiki/`. | ||
| const linkers = [ | ||
| ...listPages(projectRoot).map((p) => p.path), | ||
| ...NON_ENTITY_PAGES.map((name) => `wiki/${name}`), | ||
| ]; | ||
| for (const path of linkers) { | ||
| if (path === ref.path) continue; | ||
| const file = assertWithin(projectRoot, join(projectRoot, path)); | ||
| if (!existsSync(file)) continue; | ||
| const body = readFileSync(file, "utf8"); | ||
| const next = body.replace(wikilinkTo(from), `[[${to}$1`); | ||
| if (next !== body) { | ||
| rewrites.push({ path, content: next }); | ||
| touched.push(path); | ||
| repointed.push(path.replace(/^wiki\//, "").replace(/\.md$/, "")); | ||
| } | ||
| } | ||
|
|
||
| const operation = appendOperation(projectRoot, { | ||
| ...snapshotOf(projectRoot, touched), | ||
| origin: "editor", | ||
| }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Include wiki/index.md in the rename snapshot.
renamePage calls registerInIndex(projectRoot, to) at line 359, after the snapshot is taken. wiki/index.md enters touched only when it contained a [[from]] link that was rewritten. If index.md had no link to from, the new index entry for to is written outside the operation. Undo then removes the renamed page and leaves the index entry pointing at it, which is the broken wikilink createPage avoids by snapshotting INDEX_PAGE unconditionally (lines 185-192).
Add INDEX_PAGE to the snapshot paths. snapshot records existed per path, so including it twice is the only thing to avoid.
🛠️ Proposed fix
- const touched = [ref.path, target];
+ // `registerInIndex` touches `index.md` after the snapshot, so it is part of
+ // the operation even when no wikilink in it had to be repointed.
+ const touched = [ref.path, target, INDEX_PAGE];
@@
if (next !== body) {
rewrites.push({ path, content: next });
- touched.push(path);
+ if (path !== INDEX_PAGE) touched.push(path);
repointed.push(path.replace(/^wiki\//, "").replace(/\.md$/, ""));
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const touched = [ref.path, target]; | |
| const repointed: string[] = []; | |
| const rewrites: Array<{ path: string; content: string }> = []; | |
| // `listPages` deliberately excludes `index.md`, `changelog.md` and `log.md` | |
| // — they are not entity pages. They do carry wikilinks, and `index.md` is | |
| // guaranteed to link to this page because `registerInIndex` put it there, so | |
| // a repoint that skipped them left a broken link in the one file certain to | |
| // have one. | |
| // `NON_ENTITY_PAGES` are bare filenames; they live at the top of `wiki/`. | |
| const linkers = [ | |
| ...listPages(projectRoot).map((p) => p.path), | |
| ...NON_ENTITY_PAGES.map((name) => `wiki/${name}`), | |
| ]; | |
| for (const path of linkers) { | |
| if (path === ref.path) continue; | |
| const file = assertWithin(projectRoot, join(projectRoot, path)); | |
| if (!existsSync(file)) continue; | |
| const body = readFileSync(file, "utf8"); | |
| const next = body.replace(wikilinkTo(from), `[[${to}$1`); | |
| if (next !== body) { | |
| rewrites.push({ path, content: next }); | |
| touched.push(path); | |
| repointed.push(path.replace(/^wiki\//, "").replace(/\.md$/, "")); | |
| } | |
| } | |
| const operation = appendOperation(projectRoot, { | |
| ...snapshotOf(projectRoot, touched), | |
| origin: "editor", | |
| }); | |
| // `registerInIndex` touches `index.md` after the snapshot, so it is part of | |
| // the operation even when no wikilink in it had to be repointed. | |
| const touched = [ref.path, target, INDEX_PAGE]; | |
| const repointed: string[] = []; | |
| const rewrites: Array<{ path: string; content: string }> = []; | |
| // `listPages` deliberately excludes `index.md`, `changelog.md` and `log.md` | |
| // — they are not entity pages. They do carry wikilinks, and `index.md` is | |
| // guaranteed to link to this page because `registerInIndex` put it there, so | |
| // a repoint that skipped them left a broken link in the one file certain to | |
| // have one. | |
| // `NON_ENTITY_PAGES` are bare filenames; they live at the top of `wiki/`. | |
| const linkers = [ | |
| ...listPages(projectRoot).map((p) => p.path), | |
| ...NON_ENTITY_PAGES.map((name) => `wiki/${name}`), | |
| ]; | |
| for (const path of linkers) { | |
| if (path === ref.path) continue; | |
| const file = assertWithin(projectRoot, join(projectRoot, path)); | |
| if (!existsSync(file)) continue; | |
| const body = readFileSync(file, "utf8"); | |
| const next = body.replace(wikilinkTo(from), `[[${to}$1`); | |
| if (next !== body) { | |
| rewrites.push({ path, content: next }); | |
| if (path !== INDEX_PAGE) touched.push(path); | |
| repointed.push(path.replace(/^wiki\//, "").replace(/\.md$/, "")); | |
| } | |
| } | |
| const operation = appendOperation(projectRoot, { | |
| ...snapshotOf(projectRoot, touched), | |
| origin: "editor", | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/desktop/src/main/edit.ts` around lines 306 - 335, Include INDEX_PAGE in
the touched paths used by renamePage before calling snapshotOf, while avoiding
duplicate entries when it is already present from link rewriting. Preserve the
existing rewrite tracking and ensure the snapshot captures wiki/index.md
unconditionally before registerInIndex updates it.
| export async function ingestFile(projectRoot: string, path: string): Promise<DropOutcome> { | ||
| const name = basename(path); | ||
| const adapter = ADAPTERS[extname(name).toLowerCase()]; | ||
| if (!adapter) { | ||
| return { | ||
| name, | ||
| ok: false, | ||
| reason: `open-wiki takes ${recognisedExtensions().join(", ")} — not ${extname(name) || "a file with no extension"}`, | ||
| }; | ||
| } | ||
| try { | ||
| const content = await readFile(path); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Look for any validation of dropped paths on the main-process side.
fd -e ts -e tsx . apps/desktop/src --exec rg -n -C3 'getPathForFile|CHANNELS\.drop|ingestDrop|ingestFile'
# Look for an existing path-allowlist or size-limit helper in the access package.
rg -n -C3 'maxBytes|MAX_[A-Z_]*SIZE|assertWithin' packages/access/src | head -60Repository: protonspy/open-wiki
Length of output: 7932
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- ingest.ts ---'
cat -n apps/desktop/src/main/ingest.ts | sed -n '1,125p'
printf '%s\n' '--- ipc.ts ---'
cat -n apps/desktop/src/main/ipc.ts | sed -n '180,285p'
printf '%s\n' '--- preload and renderer drop flow ---'
cat -n apps/desktop/src/preload/index.ts 2>/dev/null | sed -n '1,90p' || true
rg -n -C8 'pathForFile|dataTransfer|drop\(|CHANNELS\.drop|bridge\(\)' apps/desktop/src --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- path helpers and package metadata ---'
cat -n packages/access/src/paths.ts | sed -n '1,110p'
rg -n 'electron|webUtils|ingestFile|ingestDrop|raw/' apps/desktop package.json apps/desktop/package.json packages --glob 'package.json' --glob '*.ts' --glob '*.tsx' | head -160Repository: protonspy/open-wiki
Length of output: 50375
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
p = Path("apps/desktop/src/main/ingest.ts")
s = p.read_text()
print("--- ingest behavior summary ---")
for i, line in enumerate(s.splitlines(), 1):
if 1 <= i <= 120:
print(f"{i}: {line}")
PYRepository: protonspy/open-wiki
Length of output: 3852
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- upload and registration path ---'
cat -n packages/access/src/sources/upload.ts | sed -n '1,180p'
cat -n packages/access/src/sources/register.ts | sed -n '1,180p'
printf '%s\n' '--- relevant desktop tests ---'
cat -n apps/desktop/tests/sources.spec.ts | sed -n '205,270p'
printf '%s\n' '--- renderer trust and window security configuration ---'
rg -n -C5 'loadURL|loadFile|contextIsolation|sandbox|nodeIntegration|webSecurity|preload|setWindowOpenHandler|will-navigate|ipcMain\.handle' apps/desktop/src/main apps/desktop/src --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- size-related source APIs ---'
rg -n -C3 'readFile\(|stat\(|size|limit|maxBytes|MAX_' packages/access/src/sources apps/desktop/src/main/ingest.tsRepository: protonspy/open-wiki
Length of output: 50375
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
for name in [
"packages/access/src/sources/upload.ts",
"packages/access/src/sources/register.ts",
]:
print(f"--- {name} ---")
lines = Path(name).read_text().splitlines()
for i, line in enumerate(lines, 1):
print(f"{i}: {line}")
PYRepository: protonspy/open-wiki
Length of output: 8396
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
ingest = Path("apps/desktop/src/main/ingest.ts").read_text()
upload = Path("packages/access/src/sources/upload.ts").read_text()
# Reproduce the extension gate used by the desktop drop path for representative
# readable paths. This is a read-only behavioral probe of the source rules.
adapters = {".md", ".markdown", ".txt", ".pdf", ".docx"}
def accepted(path):
name = path.replace("\\", "/").rsplit("/", 1)[-1]
dot = name.rfind(".")
ext = name[dot:].lower() if dot >= 0 else ""
return ext in adapters
for path in [
"/home/user/.ssh/id_rsa",
"/home/user/.config/app/token.txt",
"/home/user/Documents/notes.md",
"/tmp/archive.pdf",
"/tmp/unknown.bin",
]:
print(f"{path}: {'accepted' if accepted(path) else 'rejected'}")
print("desktop reads path directly:", "readFile(path)" in ingest)
print("desktop imports MAX_SOURCE_BYTES:", "MAX_SOURCE_BYTES" in ingest)
print("shared upload defines MAX_SOURCE_BYTES:", bool(re.search(r"MAX_SOURCE_BYTES\\s*=\\s*64 \\* 1024 \\* 1024", upload)))
PYRepository: protonspy/open-wiki
Length of output: 447
Restrict renderer-supplied paths before ingestion.
CHANNELS.drop accepts arbitrary renderer-supplied strings, and ingestFile reads recognized-extension paths without validating their origin. A compromised renderer can ingest any readable .md, .markdown, .txt, .pdf, or .docx file into raw/. Allow only files recorded for the active drop, and enforce the 64 MiB source limit before readFile.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/desktop/src/main/ingest.ts` around lines 47 - 58, Update ingestFile and
the CHANNELS.drop flow to validate renderer-supplied paths against the files
recorded for the active drop, rejecting any unapproved source before ingestion.
Enforce the 64 MiB source-size limit using file metadata before readFile, while
preserving the existing recognized-extension checks and DropOutcome error
handling.
| case CHANNELS.save: | ||
| return api.save(args[0] as SaveInput); | ||
| case CHANNELS.create: | ||
| return api.create(args[0] as CreateInput); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Validate the save and create payloads at the trust boundary.
Every other case in dispatch coerces its arguments. These two cases cast an arbitrary renderer value with as SaveInput / as CreateInput, so no field is checked. savePage in apps/desktop/src/main/edit.ts Lines 109-136 then passes input.markdown to gateWrite and on to disk. A non-string markdown reaches the write path, and a missing baseMarkdown defeats the 8.8 staleness check because isStale compares against a value the renderer never loaded.
Coerce the three fields the same way the other channels coerce their arguments.
🛡️ Proposed fix
case CHANNELS.save:
- return api.save(args[0] as SaveInput);
+ return api.save(saveInput(args[0]));
case CHANNELS.create:
- return api.create(args[0] as CreateInput);
+ return api.create(createInput(args[0]));Add the helpers beside dispatch:
function fields(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
function text(value: unknown): string {
return typeof value === "string" ? value : "";
}
function saveInput(value: unknown): SaveInput {
const raw = fields(value);
return {
slug: text(raw["slug"]),
markdown: text(raw["markdown"]),
baseMarkdown: text(raw["baseMarkdown"]),
};
}
function createInput(value: unknown): CreateInput {
const raw = fields(value);
return { slug: text(raw["slug"]), markdown: text(raw["markdown"]) };
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| case CHANNELS.save: | |
| return api.save(args[0] as SaveInput); | |
| case CHANNELS.create: | |
| return api.create(args[0] as CreateInput); | |
| case CHANNELS.save: | |
| return api.save(saveInput(args[0])); | |
| case CHANNELS.create: | |
| return api.create(createInput(args[0])); | |
| function fields(value: unknown): Record<string, unknown> { | |
| return typeof value === "object" && value !== null | |
| ? (value as Record<string, unknown>) | |
| : {}; | |
| } | |
| function text(value: unknown): string { | |
| return typeof value === "string" ? value : ""; | |
| } | |
| function saveInput(value: unknown): SaveInput { | |
| const raw = fields(value); | |
| return { | |
| slug: text(raw["slug"]), | |
| markdown: text(raw["markdown"]), | |
| baseMarkdown: text(raw["baseMarkdown"]), | |
| }; | |
| } | |
| function createInput(value: unknown): CreateInput { | |
| const raw = fields(value); | |
| return { slug: text(raw["slug"]), markdown: text(raw["markdown"]) }; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/desktop/src/main/ipc.ts` around lines 239 - 242, Update the
CHANNELS.save and CHANNELS.create branches in dispatch to validate renderer
payloads instead of casting them directly. Add dedicated coercion helpers near
dispatch that normalize object fields to strings, then construct SaveInput with
slug, markdown, and baseMarkdown and CreateInput with slug and markdown before
calling api.save or api.create.
| /** | ||
| * The parity check. If `OwBridge` gains a method this object does not have, | ||
| * this line stops compiling — which is the whole point of it existing. | ||
| */ | ||
| const _parity: (keyof OwBridge)[] = Object.keys(api) as (keyof OwBridge)[]; | ||
| void _parity; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm no other parity assertion exists and inspect the current one.
rg -n -C4 '_parity|keyof OwBridge|satisfies OwBridge' apps/desktop/srcRepository: protonspy/open-wiki
Length of output: 620
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- preload.ts structure ---'
ast-grep outline apps/desktop/src/main/preload.ts
printf '%s\n' '--- preload.ts relevant source ---'
sed -n '1,105p' apps/desktop/src/main/preload.ts
printf '%s\n' '--- OwBridge declarations and api construction ---'
rg -n -C5 'interface OwBridge|type OwBridge|const api|contextBridge|OwBridge' apps/desktop/src
printf '%s\n' '--- package TypeScript configuration ---'
fd -i 'tsconfig*.json' . -x sh -c 'echo "--- $1"; sed -n "1,220p" "$1"' sh '{}'Repository: protonspy/open-wiki
Length of output: 11552
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- OwBridge members ---'
sed -n '1,55p' apps/desktop/src/renderer/bridge.ts
printf '%s\n' '--- base compiler options ---'
sed -n '1,180p' tsconfig.base.json
printf '%s\n' '--- structural key comparison ---'
python3 - <<'PY'
from pathlib import Path
import re
preload = Path("apps/desktop/src/main/preload.ts").read_text()
bridge = Path("apps/desktop/src/renderer/bridge.ts").read_text()
api_block = re.search(r'const api = \{(.*?)\n\};', preload, re.S).group(1)
api_keys = re.findall(r'^\s{2}([A-Za-z_$][\w$]*)\s*:', api_block, re.M)
bridge_block = re.search(r'interface OwBridge\s*\{(.*?)\n\}', bridge, re.S).group(1)
bridge_keys = re.findall(r'^\s{2}([A-Za-z_$][\w$]*)\s*\(', bridge_block, re.M)
print("api keys:", api_keys)
print("OwBridge keys:", bridge_keys)
print("missing from api:", sorted(set(bridge_keys) - set(api_keys)))
print("extra in api:", sorted(set(api_keys) - set(bridge_keys)))
PY
printf '%s\n' '--- TypeScript tooling availability (read-only) ---'
command -v tsc || true
node -e 'try { console.log(require.resolve("typescript")) } catch (_) { console.log("typescript package unavailable") }' 2>/dev/null || trueRepository: protonspy/open-wiki
Length of output: 3568
🏁 Script executed:
#!/bin/bash
set -e
node - <<'JS'
const ts = require("/home/jailuser/git/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.js");
const source = `
type OwBridge = {
project(): void;
index(): void;
};
const api = { project() {} };
const castCheck: (keyof OwBridge)[] =
Object.keys(api) as (keyof OwBridge)[];
const completeCheck: Record<keyof OwBridge, unknown> = api;
const extraKeyApi = { project() {}, index() {}, extra() {} };
const extraKeysAllowed: Record<keyof OwBridge, unknown> = extraKeyApi;
`;
const fileName = "/virtual/parity.ts";
const options = {
strict: true,
noEmit: true,
target: ts.ScriptTarget.ES2023,
lib: ["lib.es2023.d.ts"],
};
const host = ts.createCompilerHost(options);
const originalGetSourceFile = host.getSourceFile.bind(host);
host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => {
if (name === fileName) {
return ts.createSourceFile(name, source, languageVersion, true);
}
return originalGetSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile);
};
host.fileExists = (name) => name === fileName || ts.sys.fileExists(name);
host.readFile = (name) => name === fileName ? source : ts.sys.readFile(name);
const program = ts.createProgram([fileName], options, host);
const diagnostics = ts.getPreEmitDiagnostics(program);
for (const diagnostic of diagnostics) {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
const start = diagnostic.start == null ? "" : ` at offset ${diagnostic.start}`;
console.log(`${message}${start}`);
}
console.log(`diagnostic count: ${diagnostics.length}`);
JSRepository: protonspy/open-wiki
Length of output: 1087
Use a complete-key parity check for OwBridge.
Object.keys(api) as (keyof OwBridge)[] only casts string[]; it does not require every OwBridge key. Use const _parity: Record<keyof OwBridge, unknown> = api.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/desktop/src/main/preload.ts` around lines 77 - 82, Replace the
incomplete _parity cast with a Record<keyof OwBridge, unknown> assignment
directly from api, so TypeScript requires api to provide every OwBridge key
while preserving the compile-time parity check.
| const paths = [...event.dataTransfer.files].map((file) => ow.pathForFile(file)).filter(Boolean); | ||
| if (paths.length === 0) { | ||
| setDropped([ | ||
| { name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" }, | ||
| ]); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
A file whose path does not resolve disappears from the drop report.
filter(Boolean) removes every path that pathForFile could not resolve. If a drop contains five files and two resolve, the report lists two outcomes and says "2 of 2 added". The unresolved files are not mentioned. Plan 3.5 asks for what was not recognised as well.
♻️ Proposed change
const ow = bridge();
- const paths = [...event.dataTransfer.files].map((file) => ow.pathForFile(file)).filter(Boolean);
- if (paths.length === 0) {
- setDropped([
- { name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" },
- ]);
- return;
- }
+ const resolved = [...event.dataTransfer.files].map((file) => ({
+ name: file.name,
+ path: ow.pathForFile(file),
+ }));
+ const unresolved: DropOutcome[] = resolved
+ .filter((entry) => !entry.path)
+ .map((entry) => ({
+ name: entry.name || "that drop",
+ ok: false,
+ reason: "it did not look like a file on disk",
+ }));
+ const paths = resolved.map((entry) => entry.path).filter(Boolean);
+ if (paths.length === 0) {
+ setDropped(
+ unresolved.length > 0
+ ? unresolved
+ : [{ name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" }],
+ );
+ return;
+ }
void ow
.drop(paths)
.then((outcomes) => {
- setDropped(outcomes);
+ setDropped([...outcomes, ...unresolved]);
setReloadKey((n) => n + 1);
})
.catch((e: unknown) => setError(message(e)));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const paths = [...event.dataTransfer.files].map((file) => ow.pathForFile(file)).filter(Boolean); | |
| if (paths.length === 0) { | |
| setDropped([ | |
| { name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" }, | |
| ]); | |
| return; | |
| } | |
| const ow = bridge(); | |
| const resolved = [...event.dataTransfer.files].map((file) => ({ | |
| name: file.name, | |
| path: ow.pathForFile(file), | |
| })); | |
| const unresolved: DropOutcome[] = resolved | |
| .filter((entry) => !entry.path) | |
| .map((entry) => ({ | |
| name: entry.name || "that drop", | |
| ok: false, | |
| reason: "it did not look like a file on disk", | |
| })); | |
| const paths = resolved.map((entry) => entry.path).filter(Boolean); | |
| if (paths.length === 0) { | |
| setDropped( | |
| unresolved.length > 0 | |
| ? unresolved | |
| : [{ name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" }], | |
| ); | |
| return; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/desktop/src/renderer/App.tsx` around lines 176 - 182, Update the
drop-processing logic around pathForFile so unresolved files remain represented
in the drop report instead of being removed by filter(Boolean). Preserve
resolved paths for normal processing, and add an explicit unsuccessful outcome
for each file whose path cannot be resolved so the final counts and messages
include every dropped file.
| const retitle = useCallback(async () => { | ||
| const next = globalThis.prompt?.("Title for this source", row.title); | ||
| if (!next || next === row.title) return; | ||
| setBusy(true); | ||
| try { | ||
| await bridge().retitle(row.id, next); | ||
| onChanged(); | ||
| } finally { | ||
| setBusy(false); | ||
| } | ||
| }, [row.id, row.title, onChanged]); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Report a failed retitle to the user.
retitle has no catch. finally clears busy, then the rejection escapes an async callback invoked as void retitle(). The row shows no message and no change, so a refused retitle is indistinguishable from a cancelled prompt. Sources already holds an error state; pass a reporter down so the failure reaches the screen.
🛠️ Proposed fix
function SourceItem({
row,
onOpenPage,
onChanged,
+ onError,
}: {
row: SourceRow;
onOpenPage: (slug: string) => void;
onChanged: () => void;
+ onError: (message: string) => void;
}): React.JSX.Element {
@@
setBusy(true);
try {
await bridge().retitle(row.id, next);
onChanged();
+ } catch (e) {
+ onError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
- }, [row.id, row.title, onChanged]);
+ }, [row.id, row.title, onChanged, onError]);and at the call site:
- <SourceItem key={row.id} row={row} onOpenPage={onOpenPage} onChanged={() => void load()} />
+ <SourceItem
+ key={row.id}
+ row={row}
+ onOpenPage={onOpenPage}
+ onChanged={() => void load()}
+ onError={setError}
+ />🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/desktop/src/renderer/Sources.tsx` around lines 66 - 76, Update the
retitle callback in Sources so failures from bridge().retitle are caught and
reported through the existing error state. Pass the error reporter through the
relevant component/call site so a rejected retitle reaches the screen, while
preserving the finally block that clears busy and the current cancellation
behavior.
| {row.citedBy.length > 0 ? ( | ||
| <p className="source__cited"> | ||
| Cited by{" "} | ||
| {row.citedBy.map((slug, i) => ( | ||
| <span key={slug}> | ||
| {i > 0 ? ", " : ""} | ||
| <a onClick={() => onOpenPage(slug)}>{slug}</a> | ||
| </span> | ||
| ))} | ||
| </p> | ||
| ) : null} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the citation links keyboard-operable.
<a onClick={...}> carries no href, so it is not in the tab order and it does not respond to Enter or Space. Navigating from a source to a citing page is the whole of plan 6.4, and a keyboard user cannot complete it. Plan 8.1 also states focus is visible on everything, always.
Use a button and style it as a link.
♿ Proposed fix
{row.citedBy.map((slug, i) => (
<span key={slug}>
{i > 0 ? ", " : ""}
- <a onClick={() => onOpenPage(slug)}>{slug}</a>
+ <button type="button" className="linkish" onClick={() => onOpenPage(slug)}>
+ {slug}
+ </button>
</span>
))}Add the matching rule to apps/desktop/src/renderer/tokens.css:
.linkish {
background: none;
border: 0;
padding: 0;
color: var(--accent);
font: inherit;
text-decoration: underline;
cursor: pointer;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {row.citedBy.length > 0 ? ( | |
| <p className="source__cited"> | |
| Cited by{" "} | |
| {row.citedBy.map((slug, i) => ( | |
| <span key={slug}> | |
| {i > 0 ? ", " : ""} | |
| <a onClick={() => onOpenPage(slug)}>{slug}</a> | |
| </span> | |
| ))} | |
| </p> | |
| ) : null} | |
| {row.citedBy.length > 0 ? ( | |
| <p className="source__cited"> | |
| Cited by{" "} | |
| {row.citedBy.map((slug, i) => ( | |
| <span key={slug}> | |
| {i > 0 ? ", " : ""} | |
| <button type="button" className="linkish" onClick={() => onOpenPage(slug)}> | |
| {slug} | |
| </button> | |
| </span> | |
| ))} | |
| </p> | |
| ) : null} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/desktop/src/renderer/Sources.tsx` around lines 93 - 103, Replace the
citation `<a>` elements in the `row.citedBy.map` rendering with
keyboard-operable buttons that call `onOpenPage(slug)`, and apply the `linkish`
class to preserve link-like styling. Add the matching `.linkish` rule to the
renderer tokens stylesheet, including visible focus styling consistent with
existing focus behavior.
| /* --- What a citation opens (8.6), beside the page rather than over it. */ | ||
| .source-at { | ||
| position: fixed; | ||
| right: var(--space-4); | ||
| bottom: var(--space-4); | ||
| width: min(480px, calc(100vw - var(--space-8))); | ||
| background: var(--surface-2); | ||
| border: 1px solid var(--line-strong); | ||
| border-radius: var(--radius); | ||
| padding: var(--space-3); | ||
| display: grid; | ||
| gap: var(--space-2); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Bound the height of the citation panel.
.source-at is fixed to the bottom right with no max-height and no overflow. A long cited passage grows the panel upward past the top of the viewport, and the part above the fold cannot be reached or scrolled. The width is already bounded with min(); bound the height the same way.
🎨 Proposed fix
width: min(480px, calc(100vw - var(--space-8)));
+ max-height: calc(100vh - var(--space-8));
+ overflow: auto;
background: var(--surface-2);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /* --- What a citation opens (8.6), beside the page rather than over it. */ | |
| .source-at { | |
| position: fixed; | |
| right: var(--space-4); | |
| bottom: var(--space-4); | |
| width: min(480px, calc(100vw - var(--space-8))); | |
| background: var(--surface-2); | |
| border: 1px solid var(--line-strong); | |
| border-radius: var(--radius); | |
| padding: var(--space-3); | |
| display: grid; | |
| gap: var(--space-2); | |
| } | |
| /* --- What a citation opens (8.6), beside the page rather than over it. */ | |
| .source-at { | |
| position: fixed; | |
| right: var(--space-4); | |
| bottom: var(--space-4); | |
| width: min(480px, calc(100vw - var(--space-8))); | |
| max-height: calc(100vh - var(--space-8)); | |
| overflow: auto; | |
| background: var(--surface-2); | |
| border: 1px solid var(--line-strong); | |
| border-radius: var(--radius); | |
| padding: var(--space-3); | |
| display: grid; | |
| gap: var(--space-2); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/desktop/src/renderer/tokens.css` around lines 468 - 480, Update the
.source-at style rule to bound its height relative to the viewport, using the
existing spacing variables, and enable internal scrolling for content that
exceeds that limit. Preserve its fixed bottom-right positioning and current
width behavior.
| for (const page of op.pages) { | ||
| // The paths come off the operation log on disk, so they are input, not | ||
| // fact: confine both ends before restoring or removing anything. Undo is | ||
| // the one operation that deletes, and a `..` in a log entry must not turn | ||
| // it into a delete of something the project never owned. | ||
| const live = assertWithin(projectRoot, join(projectRoot, page.path)); | ||
| if (!isUndoable(page.path)) { | ||
| throw new CorruptOperationError(id, `it names "${page.path}", which undo does not own`); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
isUndoable checks the unresolved path, so .. defeats the guard.
isUndoable tests a prefix on the raw page.path string. assertWithin only proves the path stays inside the project. A log entry of wiki/../.git/config satisfies both checks: the prefix is wiki/, and .git/config is inside projectRoot. With existed: false that entry still reaches rmSync, which is the exact case the doc comment says is now refused. wiki/../.state/log.jsonl, wiki/../CLAUDE.md and wiki/../.claude/rules/autonomy.md pass the same way. The backslash normalisation in isUndoable does not help, because it converts wiki\..\.git\config into the same passing string.
Derive the relative path from the confined absolute path and test that, so the prefix check sees where the write lands rather than the name it was asked for.
🛡️ Proposed fix
for (const page of op.pages) {
const live = assertWithin(projectRoot, join(projectRoot, page.path));
- if (!isUndoable(page.path)) {
+ // Check where it *lands*, not the name it was asked for: `wiki/../.git/config`
+ // is confined by `assertWithin` and still leaves `wiki/` behind.
+ if (!isUndoable(relative(resolveReal(projectRoot), live))) {
throw new CorruptOperationError(id, `it names "${page.path}", which undo does not own`);
}Add the imports:
-import { join, dirname } from "node:path";
+import { join, dirname, relative } from "node:path";and import resolveReal from ../paths.js beside assertWithin.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| for (const page of op.pages) { | |
| // The paths come off the operation log on disk, so they are input, not | |
| // fact: confine both ends before restoring or removing anything. Undo is | |
| // the one operation that deletes, and a `..` in a log entry must not turn | |
| // it into a delete of something the project never owned. | |
| const live = assertWithin(projectRoot, join(projectRoot, page.path)); | |
| if (!isUndoable(page.path)) { | |
| throw new CorruptOperationError(id, `it names "${page.path}", which undo does not own`); | |
| } | |
| for (const page of op.pages) { | |
| // The paths come off the operation log on disk, so they are input, not | |
| // fact: confine both ends before restoring or removing anything. Undo is | |
| // the one operation that deletes, and a `..` in a log entry must not turn | |
| // it into a delete of something the project never owned. | |
| const live = assertWithin(projectRoot, join(projectRoot, page.path)); | |
| // Check where it *lands*, not the name it was asked for: `wiki/../.git/config` | |
| // is confined by `assertWithin` and still leaves `wiki/` behind. | |
| if (!isUndoable(relative(resolveReal(projectRoot), live))) { | |
| throw new CorruptOperationError(id, `it names "${page.path}", which undo does not own`); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/access/src/write/undo.ts` around lines 54 - 62, Update the undo loop
around assertWithin and isUndoable so the undoability check uses the confined
destination, not the raw page.path: derive the project-relative path from live
using the appropriate path utility, then pass that normalized relative path to
isUndoable before restoring or removing. Import the required relative-path
helper and resolveReal alongside assertWithin, preserving the existing
containment validation and corruption error behavior.
| - [x] 7.6 (Unit) Expose the checks in the UI, with the correction path described per finding | ||
| - Rendering, not new checking, exactly as the deferral said. Every finding already carries its `fix`, and the panel puts it where the person reading the problem is. | ||
| - Deferred to group 8, which is where the UI is. The findings already carry the correction path — `fix` on every one — so this is rendering, not new checking. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove the stale deferral note under 7.6.
Line 244 records 7.6 as done. Line 245 still states the task is deferred to group 8 and repeats the same point about fix. Keep one note, so the plan does not describe the item as both closed and pending.
As per coding guidelines: "Keep each plan in one plans/<name>.md file containing structure plus a checklist and/or spec references."
📝 Proposed fix
- Rendering, not new checking, exactly as the deferral said. Every finding already carries its `fix`, and the panel puts it where the person reading the problem is.
- - Deferred to group 8, which is where the UI is. The findings already carry the correction path — `fix` on every one — so this is rendering, not new checking.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| - [x] 7.6 (Unit) Expose the checks in the UI, with the correction path described per finding | |
| - Rendering, not new checking, exactly as the deferral said. Every finding already carries its `fix`, and the panel puts it where the person reading the problem is. | |
| - Deferred to group 8, which is where the UI is. The findings already carry the correction path — `fix` on every one — so this is rendering, not new checking. | |
| - [x] 7.6 (Unit) Expose the checks in the UI, with the correction path described per finding | |
| - Rendering, not new checking, exactly as the deferral said. Every finding already carries its `fix`, and the panel puts it where the person reading the problem is. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@plans/open-wiki.md` around lines 243 - 245, Remove the stale “Deferred to
group 8” note beneath checklist item 7.6 in plans/open-wiki.md, preserving the
completed 7.6 entry and its single explanatory note without changing other plan
content.
Source: Coding guidelines
Closes 3.5, 6.2, 6.4, 6.5, 6.6, 6.7, 7.6, 8.6, 8.7, 8.8, 8.9 and 8.11 of
plans/open-wiki.md.6.3 is deliberately not closed — see below.
What changed
Editing goes through
gateWrite, the same entrance the agent's writes use. Group 5's claim is one implementation with three callers, and an editor that validated its own way would be a fourth quietly disagreeing with the hook about what a well-formed page is. Denial reasons are shown verbatim, because 9.13 says the message has three mouths.8.8 checks staleness before the gate — the two have different answers, "look at what changed" versus "fix this field" — and uses
isStoreOnlyChangerather than a string comparison, because a correction the store made is not somebody else's edit (5.8). When it refuses it shows both versions and merges neither.A rename repoints what pointed at the page and is one operation, so undo puts the whole thing back. A delete deliberately does not rewrite the links: they are the record that something was expected to be there, and 7.1 reports them. A rename knows where the reader should go instead; a delete does not.
The write path is a separate module from the read path.
api.tsimports@open-wiki/access/readand cannot write whatever it is asked to;edit.tsimports the barrel and can. That makes "the read surface is read-only" a fact about the import graph.What the reviews caught
The first pass shipped a PR where none of it worked: the
preload.tsedit was lost in a rebase, so every new feature typechecked, tested green, and threw "not a function" on the first click.bridge.tsasserts the shape ofwindow.owrather than deriving it, so nothing noticed. There is a type-level parity check in the preload now.Two security holes, both in the new write path:
renamecould overwriteCLAUDE.mdand.claude/rules/*— it built a path from a renderer-supplied name and was the one write that never called the gate, so 9.6's config guard was never consulted.../CLAUDEtyped into the rename prompt was enough. That is instruction injection into the agent that has tool access to the machine.create's slug escapedwiki/— and the gate has no opinion about anything outside the wiki, so the traversal also skipped every check.undois renderer-reachable now and reads.state/log.jsonl, which arrives with a clone. An uncheckedsnapshotIdcopied files from outside the project in; a page path of.git/configwithexisted: falsewas a delete.And on Windows — the platform this product targets — renaming a page to a case variant deleted it.
Full list in the follow-up commit message.
How it was verified
pnpm test— 976 passing (169 in@open-wiki/desktop)pnpm --filter @open-wiki/desktop test:coverage— above the 76% floorpnpm run typecheck,pnpm lint,prettier --check— cleanscc validate— no findingscode-reviewandsecurity-reviewsubagents on the diff; every finding closed or recorded6.3 is unticked
The per-chunk progress is on the sources screen, because
sourceStatederives it from the journal for free. There is no transcribe button and no retry-what-failed: starting a run needs the Groq credential of 8.3, and 4.15 deliberately left that wiring to the desktop application because the CLI must never read the key. It lands with 8.3.🤖 Generated with Claude Code
https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
Summary by CodeRabbit