diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..955070d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Go build IDs and embedded contract bytes must survive Windows checkouts unchanged. +/native/**/*.go text eol=lf +/native/**/go.mod text eol=lf +/native/**/go.sum text eol=lf +/mcp/contracts-v1.json text eol=lf diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5a2222a..eddbf08 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -25,10 +25,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v4 + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 with: path: site - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 81696a3..d75b78b 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -41,6 +41,7 @@ jobs: - run: npm run verify:desktop-session - run: npm run verify:backend-integrity - run: npm run verify:backend-round-two + - run: npm run verify:backend-operation-wait - run: npm run verify:mcp-reliability - run: npm run verify:mcp-lifecycle - run: npm run verify:mcp-policy-regression diff --git a/PRODUCT_TESTING.md b/PRODUCT_TESTING.md index dc4310c..5c947a1 100644 --- a/PRODUCT_TESTING.md +++ b/PRODUCT_TESTING.md @@ -42,12 +42,13 @@ The follow-up reviewed the desktop process and preload, renderer actions and asy | Terminal interaction | Bound early events, close exited message ports, avoid repeated paste on keyup, and serialize restart/disposal so reopening cannot collide with a pending terminal | `verify:renderer-async`, `verify:desktop-lifecycle`, `verify:terminal-native-lifecycle` | | Asynchronous renderer work | Keep stale searches, flat listings, comparisons, duplicates, previews and tab loads from replacing newer state; bind Apply to the submitted dialog and payload | `verify:renderer-async`, `verify:renderer-state` | | Keyboard and model UX | Preserve operation-control focus, label dialogs for assistive technology, and compute indexed STEP normals after installing the index | `verify:renderer-async`, `verify:interaction-quality`, `verify:model-preview-ui` | -| Filesystem integrity | Recheck the entire snapshot after traversal, and make legacy text Undo preserve later files while refusing directory replacement | `verify:backend-round-two`, `verify:backend-integrity` | +| Filesystem integrity | Recheck the entire snapshot and directory membership after traversal, including unchanged Windows timestamps; make legacy text Undo preserve later files while refusing directory replacement | `verify:backend-round-two`, `verify:backend-integrity` | | Search completeness | Decode bounded UTF-16 content, honor substring matches at token edges, retain fallback candidates for truncated vocabularies, and handle prototype-like names | `verify:backend-round-two`, `verify:folder-index-token-search`, `verify:background-index-token-search` | | Background index safety and freshness | Exclude followed links, mark exhausted content budgets incomplete, rebuild old stores, and count hidden entries correctly for freshness | `verify:backend-round-two`, background-index corruption/freshness/watch suites | | Native filesystem helper | Preserve FILETIME dates outside the nanosecond range, cancel work on stdin close, and wait for all active or retiring helper processes before shutdown succeeds | `go test ./...` in `native/fshelper`, `verify:desktop-lifecycle` | | MCP persisted state and policy | Retain analysis policy bindings, propagate inspection cancellation, reauthorize after UI waits, and resolve retargeted allowed roots per request | `verify:mcp-lifecycle`, `verify:mcp-policy-regression` | | MCP transport and configuration | Clean up failed startup and staged files, bound individual frames and subscriptions, order acknowledged subscription changes with reconnect replay, and preserve profiles on configuration I/O failure | `verify:mcp-lifecycle`, `verify:mcp-reliability`, native protocol tests | +| Operation completion waits | Register the listener before the initial state read, preserve cancellation, and return current authorized progress after a timeout | `verify:backend-operation-wait`, `verify:mcp-lifecycle`, `verify:mcp-operation-wait` | | Landing-page feedback | Report failed clipboard fallback truthfully and restore keyboard focus | `verify:landing-page` (mocked clipboard) | | Dependency maintenance | Pin the compatible YAML parser to 4.3.2 for the newly published [GHSA-2883-xcg3-v3hh advisory](https://github.com/advisories/GHSA-2883-xcg3-v3hh) | `npm audit`, release-feed and packaging checks | @@ -55,7 +56,13 @@ Original-source controls reproduced the desktop lifecycle, renderer ordering, le An independent differential search check tested 2,989 substring queries over 2,652 entries, including saturated postings and incomplete vocabularies: all 736,804 matching candidates were retained. The 100,000-entry browser check passed three desktop runs with median first paint of 136.7 ms, full hydration of 700.1 ms, 47 mounted file rows, and a 116.1 ms compact warm listing response. The final MCP binary measured 1.7 ms warm p95 with 15.6 MB sidecar RSS. These are local fixture measurements, not universal performance guarantees. -The final source review found no further actionable issues. Focused regressions pass: 34 desktop lifecycle checks, 8 real Electron session checks, 15 backend follow-up and 20 integrity checks, 17 renderer async and 25 state checks, and 15 MCP lifecycle checks. Existing interaction (34), model preview (15), landing page (50), SEO (265), native terminal lifecycle (24 actual exits), native filesystem/protocol tests, real MCP context/operation waits, and 19 release metadata fixtures also pass. The dependency audit reports zero known vulnerabilities at verification time. +The final source review found no further actionable issues. Focused regressions pass: 34 desktop lifecycle checks, 8 real Electron session checks, 16 backend follow-up and 20 integrity checks, 3 operation-wait race checks, 17 renderer async and 25 state checks, and 18 MCP lifecycle checks. Existing interaction (34), model preview (15), landing page (50), SEO (265), native terminal lifecycle (24 actual exits), native filesystem/protocol tests, real MCP context/operation waits, and 19 release metadata fixtures also pass. The dependency audit reports zero known vulnerabilities at verification time. + +Directory membership is verified with a transient digest of sorted entry names, independently of timestamp resolution. Deterministic fixtures hold directory metadata constant while adding or renaming entries during traversal; both changes are rejected. This adds one directory enumeration per verified directory and keeps the persisted version 1 snapshot schema and digests compatible for unchanged trees. The Pages deployment actions use their maintained Node 24 releases. + +A real transaction fixture completes while the operation wait's initial state read is deliberately held. The original listener ordering missed that completion; registering first passes ten repeated runs, and the real Electron operation-wait smoke passes without increasing its 30-second timeout. Timeout feedback now rereads and authorizes current operation state instead of returning an old queued snapshot. + +Native builds pin Go 1.25.12 and keep source/module/embedded-contract inputs in LF form. A fresh Windows checkout with `core.autocrlf=true`, a repeated build, and the workspace build produced identical binary hashes. An isolated executable-lock fixture verifies that failed MCP build publication preserves the previous binary and removes its staged candidate. Version 1 background search stores are withheld until rebuilt under the current link and encoding rules; the UI can temporarily report stale or indexing status. Existing folder token indexes remain readable through conservative full-scan fallback. Native UAC and actual installer execution remain outside the automated tests. Dedicated new suites are registered in Windows CI and `verify:all`. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 78a7394..eac9b81 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,7 +4,7 @@ ### File Safety And Recovery -- Revalidate snapshot entries after the complete traversal, and make Undo from older text saves retain later edits while refusing to replace directories. +- Revalidate snapshot entries and directory membership after the complete traversal, including changes with unchanged timestamps. Undo from older text saves retains later edits while refusing to replace directories. - Preserve UTF-16 content in direct and indexed search, return complete substring matches, skip linked descendants, and rebuild outdated background indexes automatically. - Avoid repeated background rebuilds when hidden files are excluded, and report exhausted content budgets accurately. - Save text through a staged replacement while preserving UTF-8, UTF-16LE, UTF-16BE and their byte-order marks. Undo restores the original bytes; unsupported encodings are rejected before writing. @@ -30,6 +30,7 @@ ### AI Bridge Reliability - Preserve retained analysis policy bindings, cancel recursive inspection promptly, and recheck permissions after UI waits and allowed-root changes. +- Observe operation completion even when it happens during the initial status read, and show current authorized progress when a wait times out. - Bound resource subscriptions and individual transport frames, serialize subscription state, and clean up interrupted bridge startup and staged files. - Preserve existing profiles when configuration reads fail, and require successful backup before replacing malformed configuration. - Recheck current permissions when applying previews, controlling operations, reading retained results, and dispatching actions against implicit UI targets. Permission changes invalidate old previews; collection mutations check existing contents too. @@ -49,6 +50,7 @@ - Cancel native filesystem work when its parent closes the transport, and preserve Windows timestamps outside the nanosecond conversion range. - Update the compatible YAML parser pin to 4.3.2 and correct landing-page clipboard failure feedback and focus restoration. +- Make native binaries reproducible across Windows checkouts, preserve prior binaries on failed build publication, and update Pages deployment actions to Node 24. - Updated vulnerable dependency patches, including the pinned YAML parser, and excluded local editor state and backup files from version control. - Preserves path edits during pane refreshes and keeps drive buttons at a readable minimum width. diff --git a/filesystem-integrity.mjs b/filesystem-integrity.mjs index b9931eb..c97b854 100644 --- a/filesystem-integrity.mjs +++ b/filesystem-integrity.mjs @@ -10,6 +10,12 @@ export function sameFileIdentity(left, right) { return Boolean(left && right && String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino)); } +function directoryMembershipDigest(names) { + const hash = crypto.createHash("sha256"); + for (const name of names.sort()) hash.update(JSON.stringify(name) + "\n"); + return hash.digest("hex"); +} + export async function pathSnapshot(target, { signal } = {}) { const content = crypto.createHash("sha256"); const state = crypto.createHash("sha256"); @@ -36,19 +42,22 @@ export async function pathSnapshot(target, { signal } = {}) { } else { throw new Error(`Unsupported file type in transaction: ${itemPath}`); } - observations.push({ itemPath, identity, mode: before.mode, size: before.size, mtimeMs: before.mtimeMs, ctimeMs: before.ctimeMs }); + const observation = { itemPath, identity, mode: before.mode, size: before.size, mtimeMs: before.mtimeMs, ctimeMs: before.ctimeMs }; + observations.push(observation); entries += 1; content.update(JSON.stringify([relative, kind, value]) + "\n"); state.update(JSON.stringify([relative, kind, identity, before.mode, before.size, before.mtimeMs, value]) + "\n"); if (kind === "directory") { const names = await fs.readdir(itemPath); - names.sort(); + observation.directoryDigest = directoryMembershipDigest(names); for (const name of names) await visit(path.join(itemPath, name), relative ? `${relative}/${name}` : name); } } await visit(path.resolve(target), ""); // Recheck after the entire traversal: an earlier file can change while a // sibling is hashed, and a directory can gain children after readdir. + // Compare membership too: Windows may not advance directory timestamps for + // same-tick changes. Keep this transient digest out of persisted v1 snapshots. for (const before of observations) { throwIfAborted(signal); const after = await fs.lstat(before.itemPath); @@ -56,6 +65,9 @@ export async function pathSnapshot(target, { signal } = {}) { before.ctimeMs !== after.ctimeMs || before.mode !== after.mode) { throw new Error(`File changed while verifying transaction: ${before.itemPath}`); } + if (before.directoryDigest && before.directoryDigest !== directoryMembershipDigest(await fs.readdir(before.itemPath))) { + throw new Error(`File changed while verifying transaction: ${before.itemPath}`); + } } throwIfAborted(signal); return { version: 1, identity: rootIdentity, entries, contentDigest: content.digest("hex"), stateDigest: state.digest("hex") }; diff --git a/mcp/automation-service.mjs b/mcp/automation-service.mjs index bbb646c..643d66e 100644 --- a/mcp/automation-service.mjs +++ b/mcp/automation-service.mjs @@ -1184,15 +1184,20 @@ export async function createMcpAutomationService(deps) { else request.signal?.addEventListener?.("abort", onAbort, { once: true }); }); const [data, waitedOperation] = await Promise.all([uiWait, operationWait]); - if (waitedOperation) operation = waitedOperation; - const latest = await uiDispatcher({ type: "wait", afterRevision: 0, timeoutMs: 100, condition: {}, signal: request.signal }); + const operationMatched = !operationId || Boolean(waitedOperation && (!operationStatus || waitedOperation.status === operationStatus)); + const [latest, currentOperation] = await Promise.all([ + uiDispatcher({ type: "wait", afterRevision: 0, timeoutMs: 100, condition: {}, signal: request.signal }), + operationId ? deps.getOperation(operationId) : null + ]); + // Report current progress even after a timeout, while preserving whether + // the wait itself observed the requested condition. + operation = currentOperation; const fresh = await currentPrincipal(principal); if (operation) { try { await authorizeOperation(fresh, operation); } catch { operation = null; } } - const operationMatched = !operationId || Boolean(operation && (!operationStatus || operation.status === operationStatus)); - const matched = data?.matched === true && operationMatched; + const matched = data?.matched === true && operationMatched && (!operationId || Boolean(operation)); const authorized = await contextForPrincipal(fresh, latest.context || data.context || principal.context); return resultEnvelope({ matched, diff --git a/native/bin/explore-better-fs.exe b/native/bin/explore-better-fs.exe index c71d0a1..6bb248b 100644 Binary files a/native/bin/explore-better-fs.exe and b/native/bin/explore-better-fs.exe differ diff --git a/package.json b/package.json index 9da6f74..b6da65b 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "verify:mcp-client-roots": "node scripts/mcp-client-roots-smoke.mjs", "verify:backend-integrity": "node scripts/backend-integrity-smoke.mjs", "verify:backend-round-two": "node scripts/backend-round-two-smoke.mjs", + "verify:backend-operation-wait": "node scripts/backend-operation-wait-smoke.mjs", "verify:renderer-state": "node scripts/renderer-state-regression-smoke.mjs", "verify:renderer-async": "node scripts/renderer-async-regression-smoke.mjs", "verify:clipboard-sequence": "node scripts/clipboard-sequence-smoke.mjs", diff --git a/scripts/backend-operation-wait-smoke.mjs b/scripts/backend-operation-wait-smoke.mjs new file mode 100644 index 0000000..2ef60f0 --- /dev/null +++ b/scripts/backend-operation-wait-smoke.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { createBackendFixture, waitForOperation } from "./mcp-smoke-helpers.mjs"; + +function deferred() { + let resolve; + const promise = new Promise(next => { resolve = next; }); + return { promise, resolve }; +} + +async function within(promise, label) { + let timer; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out awaiting ${label}`)), 10000); + })]); + } finally { clearTimeout(timer); } +} + +const fixture = await createBackendFixture({ access: "read-write" }); +const stateFile = path.join(fixture.temp, "LocalAppData", "ExploreBetter", "state.json"); +const original = { open: fs.open, stat: fs.stat, readFile: fs.readFile }; +const stagingReached = deferred(), releaseStaging = deferred(), completionPublished = deferred(); +let operationId; +let stateReads = 0; +let captureWaitRead = false; +let delayedSnapshotStatus; +let waitResult; +try { + await fixture.backend.setMcpUiDispatcher(async () => ({ matched: true, context: { live: true, contextRevision: 1 } })); + const target = path.join(fixture.fixture, "wait-race.txt"); + await fs.writeFile(target, "original bytes"); + const plan = await fixture.request("plan_text_write", { path: target, content: "committed bytes" }); + fs.open = async (...args) => { + if (typeof args[0] === "string" && path.dirname(args[0]) === fixture.fixture && path.basename(args[0]).startsWith(".explore-better-staging-")) { + stagingReached.resolve(); + await releaseStaging.promise; + } + return original.open(...args); + }; + operationId = (await fixture.request("apply_operation", { applyToken: plan.data.applyToken })).data.operationId; + await within(stagingReached.promise, "transaction staging"); + fixture.backend.setMcpResourceUpdatePublisher(uri => { + if (uri !== `explore-better://operations/${operationId}`) return; + original.readFile(stateFile, "utf8").then(text => { + if (JSON.parse(text).operations.find(operation => operation.id === operationId)?.status === "completed") completionPublished.resolve(); + }).catch(() => {}); + }); + fs.stat = async (...args) => { + const stat = await original.stat(...args); + // The adapter reads once for authorization, then the backend reads to + // establish its wait. Force that second read past its cached metadata. + if (args[0] === stateFile && ++stateReads === 2) { + captureWaitRead = true; + return { ...stat, mtimeMs: stat.mtimeMs + 1 }; + } + return stat; + }; + fs.readFile = async (...args) => { + const result = await original.readFile(...args); + if (args[0] === stateFile && captureWaitRead) { + captureWaitRead = false; + delayedSnapshotStatus = JSON.parse(result).operations.find(operation => operation.id === operationId)?.status; + releaseStaging.resolve(); + // Complete the real transaction while its earlier state read remains + // in flight, before returning the now-stale snapshot to the waiter. + await within(completionPublished.promise, "completion notification"); + } + return result; + }; + waitResult = await within(fixture.request("wait_for_ui", { timeoutMs: 5000, condition: { operationId, operationStatus: "completed" } }), "operation wait result"); + fs.stat = original.stat; + fs.readFile = original.readFile; + const operation = await waitForOperation(fixture.request, operationId, 5000); + const evidence = { delayedSnapshotStatus, matched: waitResult.data.matched, returnedStatus: waitResult.data.operation?.status, persistedStatus: operation.status }; + await fs.writeFile(path.resolve("artifacts/backend-operation-wait-latest.json"), JSON.stringify(evidence, null, 2)); + assert.equal(delayedSnapshotStatus, "running", JSON.stringify(evidence)); + assert.equal(operation.status, "completed", JSON.stringify(evidence)); + assert.equal(await fs.readFile(target, "utf8"), "committed bytes"); + assert.equal(waitResult.data.matched, true, `Completion published during the initial state read was lost: ${JSON.stringify(evidence)}`); + assert.equal(waitResult.data.operation.status, "completed"); + console.log("PASS Completion during an in-flight initial state read is observed"); + + const alreadyCompleted = await within(fixture.request("wait_for_ui", { timeoutMs: 100, condition: { operationId, operationStatus: "completed" } }), "already completed operation"); + assert.equal(alreadyCompleted.data.matched, true); + assert.equal(alreadyCompleted.data.operation.status, "completed"); + console.log("PASS An already completed operation matches without another notification"); + + const stateReadBlocked = deferred(), releaseStateRead = deferred(), stateReadReturned = deferred(); + const controller = new AbortController(); + stateReads = 0; + fs.stat = async (...args) => { + const stat = await original.stat(...args); + if (args[0] === stateFile && ++stateReads === 2) { + stateReadBlocked.resolve(); + await releaseStateRead.promise; + stateReadReturned.resolve(); + } + return stat; + }; + try { + const waiting = within(fixture.request("wait_for_ui", { timeoutMs: 5000, condition: { operationId, operationStatus: "running" } }, { signal: controller.signal }), "canceled operation wait"); + const rejected = assert.rejects(waiting, { code: "REQUEST_CANCELED" }); + await within(stateReadBlocked.promise, "blocked initial state read"); + controller.abort(); + // Cancellation must settle the caller while its filesystem read is blocked. + await rejected; + } finally { + releaseStateRead.resolve(); + fs.stat = original.stat; + await within(stateReadReturned.promise, "released initial state read"); + await new Promise(resolve => setImmediate(resolve)); + } + console.log("PASS Cancellation settles while the initial state read remains blocked"); + await fs.writeFile(path.resolve("artifacts/backend-operation-wait-latest.json"), JSON.stringify({ ...evidence, checks: 3, cancellationDuringRead: true, alreadyCompleted: true }, null, 2)); + console.log("Backend operation wait: 3 passed."); +} finally { + releaseStaging.resolve(); + completionPublished.resolve(); + fs.open = original.open; + fs.stat = original.stat; + fs.readFile = original.readFile; + fixture.backend.setMcpResourceUpdatePublisher(null); + await fixture.backend.setMcpUiDispatcher(null); + if (operationId) await waitForOperation(fixture.request, operationId, 5000).catch(() => {}); + await fixture.cleanup(); +} +process.exit(0); diff --git a/scripts/backend-round-two-smoke.mjs b/scripts/backend-round-two-smoke.mjs index 8e04984..34b34a0 100644 --- a/scripts/backend-round-two-smoke.mjs +++ b/scripts/backend-round-two-smoke.mjs @@ -37,16 +37,38 @@ await test("Snapshot rejects a file changed after its own hash during sibling tr assert.equal(await fs.readFile(first, "utf8"), "new bytes that must survive"); }); -await test("Snapshot rejects directory entries added after enumeration", async () => { +await test("Snapshot rejects directory entries added after enumeration with unchanged metadata", async () => { const tree = path.join(files, "snapshot-added"); await fs.mkdir(tree); const first = path.join(tree, "a.txt"); await fs.writeFile(first, "original"); + const directoryBefore = await fs.lstat(tree); const original = fs.lstat; let changed = false; fs.lstat = async (...args) => { if (!changed && args[0] === first) { changed = true; await fs.writeFile(path.join(tree, "late.txt"), "new file"); } + // Directory metadata can remain unchanged within a Windows timestamp tick. + if (args[0] === tree) return directoryBefore; return original(...args); }; try { await assert.rejects(pathSnapshot(tree), /changed while verifying transaction/); } finally { fs.lstat = original; } + assert.deepEqual((await fs.readdir(tree)).sort(), ["a.txt", "late.txt"]); +}); + +await test("Snapshot detects renamed directory members before checking stale child paths", async () => { + const tree = path.join(files, "snapshot-renamed"); await fs.mkdir(tree); + const first = path.join(tree, "a.txt"), last = path.join(tree, "z.txt"), renamed = path.join(tree, "renamed.txt"); + await fs.writeFile(first, "preserved bytes"); await fs.writeFile(last, "last file"); + const directoryBefore = await fs.lstat(tree); + const original = fs.lstat; let changed = false; + fs.lstat = async (...args) => { + if (!changed && args[0] === last) { changed = true; await fs.rename(first, renamed); } + if (args[0] === tree) return directoryBefore; + return original(...args); + }; + try { + await assert.rejects(pathSnapshot(tree), error => error.message === `File changed while verifying transaction: ${tree}`); + } finally { fs.lstat = original; } + assert.deepEqual((await fs.readdir(tree)).sort(), ["renamed.txt", "z.txt"]); + assert.equal(await fs.readFile(renamed, "utf8"), "preserved bytes"); }); await test("Content reads enforce physical byte limits for encoded text", async () => { diff --git a/scripts/build-mcp-server.mjs b/scripts/build-mcp-server.mjs index 7dba9af..12686b2 100644 --- a/scripts/build-mcp-server.mjs +++ b/scripts/build-mcp-server.mjs @@ -30,32 +30,33 @@ const result = spawnSync( ); if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); -if (result.status !== 0) { - await fs.rm(candidateOutput, { force: true }).catch(() => {}); - process.exit(result.status || 1); -} - -const executable = await fs.readFile(candidateOutput); -let reused = false; try { - const existing = await fs.readFile(output); - reused = existing.equals(executable); -} catch (error) { - if (error?.code !== "ENOENT") throw error; -} -if (reused) { + if (result.status !== 0) { + if (result.error) console.error(result.error.message); + process.exitCode = result.status || 1; + } else { + const executable = await fs.readFile(candidateOutput); + let reused = false; + try { + const existing = await fs.readFile(output); + reused = existing.equals(executable); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + // Replace atomically: if Windows has the running output locked, its + // previous executable remains available and the candidate is removed. + if (!reused) await fs.rename(candidateOutput, output); + console.log( + JSON.stringify({ + output, + bytes: executable.length, + sha256: crypto.createHash("sha256").update(executable).digest("hex"), + contractSha256: crypto.createHash("sha256").update(contract).digest("hex"), + goToolchain: "go1.25.12", + reused + }) + ); + } +} finally { await fs.rm(candidateOutput, { force: true }); -} else { - await fs.rm(output, { force: true }); - await fs.rename(candidateOutput, output); } -console.log( - JSON.stringify({ - output, - bytes: executable.length, - sha256: crypto.createHash("sha256").update(executable).digest("hex"), - contractSha256: crypto.createHash("sha256").update(contract).digest("hex"), - goToolchain: "go1.25.12", - reused - }) -); diff --git a/scripts/build-native-helper.mjs b/scripts/build-native-helper.mjs index a850d74..a40a29a 100644 --- a/scripts/build-native-helper.mjs +++ b/scripts/build-native-helper.mjs @@ -27,7 +27,7 @@ async function main() { await fs.mkdir(outputDir, { recursive: true }); await run("go", ["build", "-buildvcs=false", "-trimpath", "-ldflags", "-s -w", "-o", outputPath, "."], { cwd: sourceDir, - env: { ...process.env, CGO_ENABLED: "0", GOOS: process.platform === "win32" ? "windows" : process.platform, GOARCH: "amd64" } + env: { ...process.env, CGO_ENABLED: "0", GOTOOLCHAIN: "go1.25.12", GOOS: process.platform === "win32" ? "windows" : process.platform, GOARCH: "amd64" } }); const bytes = await fs.readFile(outputPath); const report = { @@ -35,6 +35,7 @@ async function main() { path: outputPath, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), + goToolchain: "go1.25.12", protocolVersion: 1 }; await fs.mkdir(path.join(root, "artifacts"), { recursive: true }); diff --git a/scripts/mcp-lifecycle-smoke.mjs b/scripts/mcp-lifecycle-smoke.mjs index cce4618..79bf547 100644 --- a/scripts/mcp-lifecycle-smoke.mjs +++ b/scripts/mcp-lifecycle-smoke.mjs @@ -220,6 +220,62 @@ test("UI waits enforce profile revocation before returning their final context", }), { code: "UNKNOWN_PROFILE" }); }); +async function timedOutOperationFixture(t, { status = "running", onRefresh } = {}) { + const f = await fixture(t); + let operation, profile, reads = 0; + const service = await f.service({ + startOperation: async (type, _body, principal) => { + operation = { id: "fixture-operation", type, label: "Fixture text write", status: "queued", + createdAt: "2026-09-09T01:00:00.000Z", startedAt: null, progress: null, + mcpProfileId: principal.profileId, mcpSessionId: principal.sessionId, mcpPolicy: principal.operationPolicy }; + return structuredClone(operation); + }, + getOperation: async () => { + if (++reads > 1) await onRefresh?.(service, profile); + return structuredClone(operation); + }, + waitForOperation: async () => { + operation = { ...operation, status, startedAt: "2026-09-09T01:00:01.000Z", updatedAt: "2026-09-09T01:00:02.000Z", + progress: { phase: "Writing", completed: 4, total: 10 } }; + return null; + } + }); + await service.configure({ enabled: true }); + profile = await service.upsertProfile({ name: "Operation wait fixture", access: "read-write", roots: [f.folder] }); + service.setUiDispatcher(async () => ({ matched: true, context: { live: true, contextRevision: 2 } })); + const request = (tool, args) => service.invoke({ profileId: profile.id, sessionId: "fixture", tool, args }); + const plan = await request("plan_text_write", { path: f.sample, content: "Fixture planned text" }); + const applied = await request("apply_operation", { applyToken: plan.data.applyToken }); + return { wait: () => request("wait_for_ui", { timeoutMs: 100, condition: { operationId: applied.data.operationId, operationStatus: "completed" } }) }; +} + +test("timed-out operation waits return current progress instead of their initial queued snapshot", async (t) => { + const h = await timedOutOperationFixture(t); + const result = await h.wait(); + assert.equal(result.data.matched, false); + assert.equal(result.data.reason, "timeout"); + assert.equal(result.data.operation.status, "running"); + assert.equal(result.data.operation.startedAt, "2026-09-09T01:00:01.000Z"); + assert.deepEqual(result.data.operation.progress, { phase: "Writing", completed: 4, total: 10 }); +}); + +test("completion seen only in the final refresh does not rewrite a timed-out wait outcome", async (t) => { + const h = await timedOutOperationFixture(t, { status: "completed" }); + const result = await h.wait(); + assert.equal(result.data.operation.status, "completed"); + assert.equal(result.data.matched, false); + assert.equal(result.data.reason, "timeout"); +}); + +test("refreshed operation feedback is reauthorized after a permission change during the refresh", async (t) => { + const h = await timedOutOperationFixture(t, { onRefresh: async (service, profile) => { + await service.upsertProfile({ ...profile, tools: profile.tools.filter(name => name !== "plan_text_write") }); + } }); + const result = await h.wait(); + assert.equal(result.data.matched, false); + assert.equal(Object.hasOwn(result.data, "operation"), false); +}); + test("configured junction roots follow their current target on the next request", async (t) => { const f = await fixture(t); const first = path.join(f.folder, "first"); diff --git a/scripts/mcp-operation-wait-smoke.mjs b/scripts/mcp-operation-wait-smoke.mjs index 20b39e8..67290ff 100644 --- a/scripts/mcp-operation-wait-smoke.mjs +++ b/scripts/mcp-operation-wait-smoke.mjs @@ -46,7 +46,25 @@ try { condition: { operationId, operationStatus: "completed" } }); const elapsedMs = performance.now() - waitStartedAt; - assert(toolData(completed)?.matched === true && toolData(completed)?.operation?.status === "completed", `Operation wait did not match completion: ${serialized(completed)}`); + const completionMatched = toolData(completed)?.matched === true && toolData(completed)?.operation?.status === "completed"; + if (!completionMatched) { + let latest; + try { latest = await callTool("get_operation", { operationId }); } + catch (error) { latest = { diagnosticError: error.message }; } + const logs = harness.logs(); + const latestOperation = toolData(latest)?.operation; + console.error(`MCP operation wait diagnostics: ${JSON.stringify({ + operationId, + elapsedMs: Math.round(elapsedMs), + latestStatus: latestOperation?.status || null, + latestStartedAt: latestOperation?.startedAt || null, + latestFinishedAt: latestOperation?.finishedAt || null, + latest: serialized(latest).slice(-20_000), + electronLog: logs.electronLog.slice(-20_000), + sidecarError: logs.sidecarError.slice(-20_000) + })}`); + } + assert(completionMatched, `Operation wait did not match completion: ${serialized(completed)}`); const notification = await waitFor( () => harness.notifications.find((item) => item.method === "notifications/resources/updated" && item.params?.uri === uri), 5_000, diff --git a/scripts/verify-all.mjs b/scripts/verify-all.mjs index b682b02..6be7fae 100644 --- a/scripts/verify-all.mjs +++ b/scripts/verify-all.mjs @@ -62,6 +62,7 @@ const coreSuites = [ ["terminal-native-lifecycle", "scripts/terminal-native-lifecycle-smoke.mjs", 120000], ["backend-integrity", "scripts/backend-integrity-smoke.mjs", 120000], ["backend-round-two", "scripts/backend-round-two-smoke.mjs", 120000], + ["backend-operation-wait", "scripts/backend-operation-wait-smoke.mjs", 60000], ["renderer-state", "scripts/renderer-state-regression-smoke.mjs", 120000], ["renderer-async", "scripts/renderer-async-regression-smoke.mjs", 120000], ["clipboard-sequence", "scripts/clipboard-sequence-smoke.mjs", 60000], diff --git a/server.mjs b/server.mjs index 6712786..f0e75ed 100644 --- a/server.mjs +++ b/server.mjs @@ -2899,8 +2899,6 @@ async function saveOperation(operation) { } async function waitForOperationCondition(operationId, status = "", timeoutMs = 10_000, signal = null) { - const current = (await readState()).operations.find((operation) => operation.id === operationId) || null; - if (current && (!status || current.status === status)) return current; return new Promise((resolve, reject) => { let settled = false; let timeout = null; @@ -2928,6 +2926,12 @@ async function waitForOperationCondition(operationId, status = "", timeoutMs = 1 operationChangeWaiters.add(waiter); if (signal?.aborted) onAbort(); else signal?.addEventListener?.("abort", onAbort, { once: true }); + // Subscribe before reading: a transaction may finish while the async state + // read is in flight, leaving its earlier snapshot behind the notification. + if (!settled) readState().then((state) => { + const current = state.operations.find((operation) => operation.id === operationId) || null; + if (current && (!status || current.status === status)) waiter.finish(current); + }, (error) => waiter.finish(null, error)); }); }