wiki-viewer 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/routes-manifest.json +6 -0
- package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/download/route/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route/build-manifest.json +9 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route/server-reference-manifest.json +4 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js +15 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.map +5 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js +5 -5
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/signin/page.js +1 -1
- package/.next/standalone/.next/server/app/signin/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
- package/.next/standalone/.next/server/chunks/0p91_next_dist_esm_build_templates_app-route_0ytral2.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0p12-6~._.js → [root-of-the-server]__094q5bj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0w47wil._.js → [root-of-the-server]__0km8suj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__12qerfy._.js → [root-of-the-server]__0mzo03s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0e7si9p._.js → [root-of-the-server]__0rzo40z._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t5mg.n._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0zi3nkn._.js → [root-of-the-server]__106uw_c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0qhdf-p._.js → [root-of-the-server]__12800b1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_download_route_actions_0rd6p-3.js +3 -0
- package/.next/standalone/.next/server/chunks/node_modules__pnpm_0aj5g-w._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_00650~e._.js → 0~w0_katex_dist_katex_mjs_0b8ra51._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[externals]__0jyyo2t._.js → [externals]__05-uq57._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[externals]__0m6xq-c._.js → [externals]__0btd01s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06mzg2t._.js → [root-of-the-server]__00lk0r~._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12ahxbl._.js → [root-of-the-server]__0ytvlzs._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +7 -7
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0v_nwsz._.js → node_modules__pnpm_0-x6zex._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0tkiej3._.js → node_modules__pnpm_012nt.s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~2~c30._.js → node_modules__pnpm_07rxv2x._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0gbgliv._.js → node_modules__pnpm_0a4su30._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_10413dq._.js → node_modules__pnpm_0ajzar9._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0zzzsrj._.js → node_modules__pnpm_0bum3-3._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wffzo0._.js → node_modules__pnpm_0c71i8a._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_099cv.b._.js → node_modules__pnpm_0cbsnwf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f_uzgy._.js → node_modules__pnpm_0cxwhfd._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03ij5.g._.js → node_modules__pnpm_0dboa_e._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0iojvye._.js → node_modules__pnpm_0de8xt-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02gx8km._.js → node_modules__pnpm_0ezm2us._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0m5-azh._.js → node_modules__pnpm_0hobcbs._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~.6dgb._.js → node_modules__pnpm_0hrbz84._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0rebktf._.js → node_modules__pnpm_0i.pw_m._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03v21p-._.js → node_modules__pnpm_0il8j.j._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0cwpt9p._.js → node_modules__pnpm_0j3mvgg._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0fixtms._.js → node_modules__pnpm_0j53hti._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0li2vzs._.js → node_modules__pnpm_0kwc-vs._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_05qsply._.js → node_modules__pnpm_0lm8e2c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_08ibh0w._.js → node_modules__pnpm_0mdo4i3._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_06pgebo._.js → node_modules__pnpm_0o.c1z8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0gofu~q._.js → node_modules__pnpm_0rw91-f._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0n1kfcp._.js → node_modules__pnpm_0xrmzh1._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02mew~s._.js → node_modules__pnpm_0yq5z.h._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xxwmv2._.js → node_modules__pnpm_0z~h2ow._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f9fpy5._.js → node_modules__pnpm_0~ln0by._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wouz19._.js → node_modules__pnpm_11e~j49._.js} +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/static/chunks/{0-qve5g~myvo-.js → 0vwkqw_rofm-c.js} +7 -7
- package/.next/standalone/docs/agent-fs-plan.md +3 -1
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +45 -0
- package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +114 -7
- package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +58 -3
- package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +2 -0
- package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
- package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +121 -12
- package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +35 -5
- package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +10 -0
- package/.next/standalone/pnpm-lock.yaml +3 -3
- package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +173 -81
- package/.next/standalone/src/app/api/wiki/download/route.ts +99 -0
- package/.next/standalone/src/app/page.tsx +20 -0
- package/.next/standalone/src/tests/proof/agent-fs.test.ts +149 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → H0E5wVJm9tAuN5CrU6zG2}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → H0E5wVJm9tAuN5CrU6zG2}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → H0E5wVJm9tAuN5CrU6zG2}/_ssgManifest.js +0 -0
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
isMarkdown,
|
|
25
25
|
atomicWrite,
|
|
26
26
|
ensureParentDir,
|
|
27
|
+
looksLikeBinary,
|
|
27
28
|
} from "@/lib/proof/raw-fs";
|
|
28
29
|
import { computeCollabState } from "@/lib/proof/collab-state";
|
|
29
30
|
|
|
@@ -125,16 +126,28 @@ export async function GET(
|
|
|
125
126
|
});
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
// ── PUT
|
|
129
|
+
// ── Shared mutation path (PUT + PATCH) ────────────────────────────────────────
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Single code path for all Tier-1 byte mutations so PUT and PATCH cannot drift
|
|
133
|
+
* on auth/scope/If-Match/R6/atomicWrite/reconcile/audit.
|
|
134
|
+
*
|
|
135
|
+
* `computeNewBody` receives the existing file buffer (or null if absent) and
|
|
136
|
+
* returns the new bytes, or an error response to short-circuit (e.g. PATCH
|
|
137
|
+
* match-count mismatch). `op` is the audit verb ("put" | "patch").
|
|
138
|
+
*/
|
|
139
|
+
async function applyMutation(
|
|
131
140
|
req: Request,
|
|
132
|
-
|
|
141
|
+
segments: string[],
|
|
142
|
+
opts: {
|
|
143
|
+
op: "put" | "patch";
|
|
144
|
+
allowCreate: boolean;
|
|
145
|
+
computeNewBody: (existing: Buffer | null) => Buffer | NextResponse;
|
|
146
|
+
},
|
|
133
147
|
): Promise<NextResponse> {
|
|
134
148
|
const auth = await checkAuth(req);
|
|
135
149
|
if (!auth.ok) return errJson("UNAUTHORIZED", auth.message ?? "Unauthorized", 401);
|
|
136
150
|
|
|
137
|
-
const { path: segments } = await params;
|
|
138
151
|
const relPath = rel(segments);
|
|
139
152
|
const url = new URL(req.url);
|
|
140
153
|
const mkdirs = url.searchParams.get("mkdirs") === "true";
|
|
@@ -150,56 +163,52 @@ export async function PUT(
|
|
|
150
163
|
if (!scope.ok) return errJson(scope.code, scope.message, 403);
|
|
151
164
|
|
|
152
165
|
const ifMatch = req.headers.get("if-match");
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
// Check existing file
|
|
157
|
-
let existingSha: string | undefined;
|
|
158
|
-
let existed = false;
|
|
159
|
-
try {
|
|
160
|
-
const existing = await readFile(absPath);
|
|
161
|
-
existingSha = sha256ofBuf(existing);
|
|
162
|
-
existed = true;
|
|
163
|
-
} catch (e) {
|
|
164
|
-
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
165
|
-
}
|
|
166
|
+
const ifCollabMatch = req.headers.get("if-collab-match");
|
|
167
|
+
const rootDir = getRootDir();
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
// The whole read-existing → precondition → compute → write → reconcile → audit
|
|
170
|
+
// sequence runs inside the mutex for .md (so R6 and reconcile are atomic with
|
|
171
|
+
// the write); non-.md runs it without the mutex.
|
|
172
|
+
const doMutation = async (): Promise<NextResponse> => {
|
|
173
|
+
// Read existing
|
|
174
|
+
let existingBuf: Buffer | null = null;
|
|
175
|
+
let existingSha: string | undefined;
|
|
176
|
+
try {
|
|
177
|
+
existingBuf = await readFile(absPath);
|
|
178
|
+
existingSha = sha256ofBuf(existingBuf);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
175
181
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
182
|
+
const existed = existingBuf !== null;
|
|
183
|
+
|
|
184
|
+
if (existed) {
|
|
185
|
+
// Overwrite: If-Match required by default (unless ?force=true)
|
|
186
|
+
if (!force && !ifMatch) {
|
|
187
|
+
return errJson(
|
|
188
|
+
"PRECONDITION_REQUIRED",
|
|
189
|
+
"If-Match header required for overwrites (use ?force=true to bypass with audit)",
|
|
190
|
+
412,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (ifMatch && extractShaHex(ifMatch) !== extractShaHex(existingSha!)) {
|
|
180
194
|
return errJson("PRECONDITION_FAILED", "If-Match sha256 mismatch", 412);
|
|
181
195
|
}
|
|
196
|
+
} else {
|
|
197
|
+
if (!opts.allowCreate) {
|
|
198
|
+
return errJson("NOT_FOUND", "File not found", 404);
|
|
199
|
+
}
|
|
200
|
+
const parentOk = await ensureParentDir(absPath, mkdirs);
|
|
201
|
+
if (!parentOk) {
|
|
202
|
+
return errJson(
|
|
203
|
+
"PARENT_NOT_FOUND",
|
|
204
|
+
"Parent directory does not exist (use ?mkdirs=true to create)",
|
|
205
|
+
400,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
182
208
|
}
|
|
183
|
-
} else {
|
|
184
|
-
// Create: ensure parent dir exists
|
|
185
|
-
const parentOk = await ensureParentDir(absPath, mkdirs);
|
|
186
|
-
if (!parentOk) {
|
|
187
|
-
return errJson(
|
|
188
|
-
"PARENT_NOT_FOUND",
|
|
189
|
-
"Parent directory does not exist (use ?mkdirs=true to create)",
|
|
190
|
-
400,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
209
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const ifCollabMatch = req.headers.get("if-collab-match");
|
|
198
|
-
|
|
199
|
-
if (isMarkdown(relPath)) {
|
|
200
|
-
// R1: acquire shared mutex; R2: reconcile eagerly inside it
|
|
201
|
-
return await withFileMutex(relPath, async () => {
|
|
202
|
-
// R6: atomic collab-state check (closes TOCTOU race)
|
|
210
|
+
// R6: for .md, re-check collab state atomically (inside mutex)
|
|
211
|
+
if (isMarkdown(relPath)) {
|
|
203
212
|
const { state, revision, snapshotUrl } = await computeCollabState(rootDir, relPath);
|
|
204
213
|
if (state === "active") {
|
|
205
214
|
const matchVal = ifCollabMatch?.trim();
|
|
@@ -216,9 +225,17 @@ export async function PUT(
|
|
|
216
225
|
);
|
|
217
226
|
}
|
|
218
227
|
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Compute new bytes (PUT: request body; PATCH: str-replace on existing)
|
|
231
|
+
const computed = opts.computeNewBody(existingBuf);
|
|
232
|
+
if (computed instanceof NextResponse) return computed;
|
|
233
|
+
const bodyBuf = computed;
|
|
234
|
+
const newSha = sha256ofBuf(bodyBuf);
|
|
219
235
|
|
|
220
|
-
|
|
236
|
+
await atomicWrite(absPath, bodyBuf);
|
|
221
237
|
|
|
238
|
+
if (isMarkdown(relPath)) {
|
|
222
239
|
const content = bodyBuf.toString("utf-8");
|
|
223
240
|
const sidecar = (await readSidecar(rootDir, relPath)) ?? emptySidecar(relPath);
|
|
224
241
|
await reconcileSidecar({
|
|
@@ -230,44 +247,119 @@ export async function PUT(
|
|
|
230
247
|
eventType: "file.rawWritten",
|
|
231
248
|
fingerprint: newSha,
|
|
232
249
|
});
|
|
250
|
+
}
|
|
233
251
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
252
|
+
writeAuditRow({
|
|
253
|
+
agentId: auth.agent.id,
|
|
254
|
+
op: opts.op,
|
|
255
|
+
path: relPath,
|
|
256
|
+
oldSha: existingSha,
|
|
257
|
+
newSha,
|
|
258
|
+
forced: force,
|
|
259
|
+
});
|
|
242
260
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
});
|
|
261
|
+
const st = await stat(absPath);
|
|
262
|
+
return NextResponse.json({
|
|
263
|
+
path: relPath,
|
|
264
|
+
sha256: newSha,
|
|
265
|
+
size: st.size,
|
|
266
|
+
mtime: st.mtime.toISOString(),
|
|
267
|
+
created: !existed,
|
|
251
268
|
});
|
|
252
|
-
}
|
|
269
|
+
};
|
|
253
270
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
271
|
+
return isMarkdown(relPath) ? withFileMutex(relPath, doMutation) : doMutation();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── PUT ──────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
export async function PUT(
|
|
277
|
+
req: Request,
|
|
278
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
279
|
+
): Promise<NextResponse> {
|
|
280
|
+
const { path: segments } = await params;
|
|
281
|
+
const bodyBuf = Buffer.from(await req.arrayBuffer());
|
|
282
|
+
return applyMutation(req, segments, {
|
|
258
283
|
op: "put",
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
newSha,
|
|
262
|
-
forced: force,
|
|
284
|
+
allowCreate: true,
|
|
285
|
+
computeNewBody: () => bodyBuf,
|
|
263
286
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── PATCH ─────────────────────────────────────────────────────────────────────
|
|
290
|
+
// Server-side str-replace so agents send only the change, not the whole file.
|
|
291
|
+
// Strict: exact substring (no regex), text/UTF-8 only, If-Match required,
|
|
292
|
+
// expectedOccurrences must match exactly (default 1). Shares applyMutation, so
|
|
293
|
+
// lock / R6 / reconcile / audit behave identically to PUT.
|
|
294
|
+
|
|
295
|
+
const MAX_PATCH_STRING = 1_000_000; // 1MB cap on find/replace strings
|
|
296
|
+
|
|
297
|
+
export async function PATCH(
|
|
298
|
+
req: Request,
|
|
299
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
300
|
+
): Promise<NextResponse> {
|
|
301
|
+
const { path: segments } = await params;
|
|
302
|
+
|
|
303
|
+
let body: { find?: unknown; replace?: unknown; expectedOccurrences?: unknown };
|
|
304
|
+
try {
|
|
305
|
+
body = (await req.json()) as typeof body;
|
|
306
|
+
} catch {
|
|
307
|
+
return errJson("INVALID_PAYLOAD", "Body must be JSON {find, replace, expectedOccurrences?}", 400);
|
|
308
|
+
}
|
|
309
|
+
const find = body.find;
|
|
310
|
+
const replace = body.replace;
|
|
311
|
+
if (typeof find !== "string" || typeof replace !== "string") {
|
|
312
|
+
return errJson("INVALID_PAYLOAD", "find and replace must be strings", 400);
|
|
313
|
+
}
|
|
314
|
+
if (find.length === 0) {
|
|
315
|
+
return errJson("INVALID_PAYLOAD", "find must not be empty", 400);
|
|
316
|
+
}
|
|
317
|
+
if (find.length > MAX_PATCH_STRING || replace.length > MAX_PATCH_STRING) {
|
|
318
|
+
return errJson("PAYLOAD_TOO_LARGE", "find/replace exceeds 1MB limit", 413);
|
|
319
|
+
}
|
|
320
|
+
let expected = 1;
|
|
321
|
+
if (body.expectedOccurrences !== undefined) {
|
|
322
|
+
if (typeof body.expectedOccurrences !== "number" || !Number.isInteger(body.expectedOccurrences) || body.expectedOccurrences < 1) {
|
|
323
|
+
return errJson("INVALID_PAYLOAD", "expectedOccurrences must be a positive integer", 400);
|
|
324
|
+
}
|
|
325
|
+
expected = body.expectedOccurrences;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return applyMutation(req, segments, {
|
|
329
|
+
op: "patch",
|
|
330
|
+
allowCreate: false, // patch only edits existing files
|
|
331
|
+
computeNewBody: (existing) => {
|
|
332
|
+
if (existing === null) return errJson("NOT_FOUND", "File not found", 404);
|
|
333
|
+
if (looksLikeBinary(existing)) {
|
|
334
|
+
return errJson("UNSUPPORTED", "Cannot patch binary / non-text file", 415);
|
|
335
|
+
}
|
|
336
|
+
let text: string;
|
|
337
|
+
try {
|
|
338
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(existing);
|
|
339
|
+
} catch {
|
|
340
|
+
return errJson("UNSUPPORTED", "File is not valid UTF-8", 415);
|
|
341
|
+
}
|
|
342
|
+
// Count exact occurrences (no regex).
|
|
343
|
+
let count = 0;
|
|
344
|
+
let idx = text.indexOf(find);
|
|
345
|
+
while (idx !== -1) {
|
|
346
|
+
count++;
|
|
347
|
+
idx = text.indexOf(find, idx + find.length);
|
|
348
|
+
}
|
|
349
|
+
if (count !== expected) {
|
|
350
|
+
return NextResponse.json(
|
|
351
|
+
{
|
|
352
|
+
error: "MATCH_COUNT_MISMATCH",
|
|
353
|
+
message: `Expected ${expected} occurrence(s) of find, found ${count}. Re-read the file or adjust expectedOccurrences.`,
|
|
354
|
+
found: count,
|
|
355
|
+
expected,
|
|
356
|
+
},
|
|
357
|
+
{ status: 422 },
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
// Replace all (count == expected) occurrences, literally.
|
|
361
|
+
return Buffer.from(text.split(find).join(replace), "utf-8");
|
|
362
|
+
},
|
|
271
363
|
});
|
|
272
364
|
}
|
|
273
365
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import JSZip from "jszip";
|
|
6
|
+
import { NextResponse } from "next/server";
|
|
7
|
+
import { requireUser } from "@/lib/auth/server";
|
|
8
|
+
import { safeRootPath } from "@/lib/root-dir";
|
|
9
|
+
|
|
10
|
+
// Skip noise that should never end up in a downloaded archive.
|
|
11
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".next", ".proof"]);
|
|
12
|
+
|
|
13
|
+
function contentDisposition(filename: string): string {
|
|
14
|
+
// RFC 5987: ASCII fallback + UTF-8 encoded form for non-ASCII names.
|
|
15
|
+
const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "'");
|
|
16
|
+
const encoded = encodeURIComponent(filename);
|
|
17
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function addDirToZip(
|
|
21
|
+
zip: JSZip,
|
|
22
|
+
absDir: string,
|
|
23
|
+
zipPrefix: string,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const names = await readdir(absDir);
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
28
|
+
const abs = path.join(absDir, name);
|
|
29
|
+
const info = await stat(abs);
|
|
30
|
+
const zipPath = zipPrefix ? `${zipPrefix}/${name}` : name;
|
|
31
|
+
if (info.isDirectory()) {
|
|
32
|
+
await addDirToZip(zip, abs, zipPath);
|
|
33
|
+
} else if (info.isFile()) {
|
|
34
|
+
zip.file(zipPath, await readFile(abs));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function GET(request: Request) {
|
|
40
|
+
const auth = await requireUser(request);
|
|
41
|
+
if (!auth.ok)
|
|
42
|
+
return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
|
43
|
+
|
|
44
|
+
const { searchParams } = new URL(request.url);
|
|
45
|
+
const rel = searchParams.get("path") ?? "";
|
|
46
|
+
const target = safeRootPath(rel);
|
|
47
|
+
if (!target)
|
|
48
|
+
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
|
49
|
+
|
|
50
|
+
let info: Awaited<ReturnType<typeof stat>>;
|
|
51
|
+
try {
|
|
52
|
+
info = await stat(target);
|
|
53
|
+
} catch {
|
|
54
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const baseName = path.basename(target) || "download";
|
|
58
|
+
|
|
59
|
+
if (info.isFile()) {
|
|
60
|
+
const webStream = Readable.toWeb(
|
|
61
|
+
createReadStream(target),
|
|
62
|
+
) as ReadableStream;
|
|
63
|
+
return new Response(webStream, {
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/octet-stream",
|
|
66
|
+
"Content-Length": String(info.size),
|
|
67
|
+
"Content-Disposition": contentDisposition(baseName),
|
|
68
|
+
"Cache-Control": "private, no-store",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (info.isDirectory()) {
|
|
74
|
+
const zip = new JSZip();
|
|
75
|
+
try {
|
|
76
|
+
await addDirToZip(zip, target, "");
|
|
77
|
+
} catch {
|
|
78
|
+
return NextResponse.json(
|
|
79
|
+
{ error: "Failed to read folder" },
|
|
80
|
+
{ status: 500 },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const buffer = await zip.generateAsync({
|
|
84
|
+
type: "nodebuffer",
|
|
85
|
+
compression: "DEFLATE",
|
|
86
|
+
compressionOptions: { level: 6 },
|
|
87
|
+
});
|
|
88
|
+
return new Response(new Uint8Array(buffer), {
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/zip",
|
|
91
|
+
"Content-Length": String(buffer.length),
|
|
92
|
+
"Content-Disposition": contentDisposition(`${baseName}.zip`),
|
|
93
|
+
"Cache-Control": "private, no-store",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return NextResponse.json({ error: "Unsupported path" }, { status: 400 });
|
|
99
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Check,
|
|
7
7
|
ChevronDown,
|
|
8
8
|
ChevronRight,
|
|
9
|
+
Download,
|
|
9
10
|
File,
|
|
10
11
|
FilePlus,
|
|
11
12
|
FileText,
|
|
@@ -742,6 +743,16 @@ export default function Page() {
|
|
|
742
743
|
}
|
|
743
744
|
}
|
|
744
745
|
|
|
746
|
+
function handleDownload(node: TreeNode) {
|
|
747
|
+
const url = `/api/wiki/download?path=${encodeURIComponent(node.path)}`;
|
|
748
|
+
const a = document.createElement("a");
|
|
749
|
+
a.href = url;
|
|
750
|
+
a.download = node.type === "file" ? node.name : `${node.name}.zip`;
|
|
751
|
+
document.body.appendChild(a);
|
|
752
|
+
a.click();
|
|
753
|
+
a.remove();
|
|
754
|
+
}
|
|
755
|
+
|
|
745
756
|
async function handleDelete() {
|
|
746
757
|
if (!deletingPath) return;
|
|
747
758
|
await fetch("/api/wiki", {
|
|
@@ -956,6 +967,15 @@ export default function Page() {
|
|
|
956
967
|
</Button>
|
|
957
968
|
</>
|
|
958
969
|
)}
|
|
970
|
+
<Button
|
|
971
|
+
size="sm"
|
|
972
|
+
variant="ghost"
|
|
973
|
+
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
|
974
|
+
title={node.type === "file" ? "Download" : "Download as zip"}
|
|
975
|
+
onClick={() => handleDownload(node)}
|
|
976
|
+
>
|
|
977
|
+
<Download className="h-3 w-3" />
|
|
978
|
+
</Button>
|
|
959
979
|
<Button
|
|
960
980
|
size="sm"
|
|
961
981
|
variant="ghost"
|
|
@@ -23,6 +23,7 @@ import { _resetAuditDb } from "../../lib/proof/audit.js";
|
|
|
23
23
|
// Route handlers (loaded after env is set)
|
|
24
24
|
let fileGET: (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
|
25
25
|
let filePUT: (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
|
26
|
+
let filePATCH: (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
|
26
27
|
let fileDELETE: (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
|
27
28
|
let lsGET: (req: Request, ctx: { params: Promise<{ path?: string[] }> }) => Promise<Response>;
|
|
28
29
|
let movePOST: (req: Request) => Promise<Response>;
|
|
@@ -103,6 +104,7 @@ before(async () => {
|
|
|
103
104
|
const fileRoute = await import("../../app/api/agent/fs/file/[...path]/route.js");
|
|
104
105
|
fileGET = fileRoute.GET;
|
|
105
106
|
filePUT = fileRoute.PUT;
|
|
107
|
+
filePATCH = fileRoute.PATCH;
|
|
106
108
|
fileDELETE = fileRoute.DELETE;
|
|
107
109
|
|
|
108
110
|
const lsRoute = await import("../../app/api/agent/fs/ls/[[...path]]/route.js");
|
|
@@ -822,3 +824,150 @@ test("traversal: .proof/ denied on PUT", async () => {
|
|
|
822
824
|
const res = await filePUT(req, makeCtx([".proof", "inject.json"]));
|
|
823
825
|
assert.equal(res.status, 400);
|
|
824
826
|
});
|
|
827
|
+
|
|
828
|
+
// ── PATCH (server-side str-replace) ──────────────────────────────────────────
|
|
829
|
+
|
|
830
|
+
test("PATCH: str-replace on .md succeeds, sends only find/replace", async () => {
|
|
831
|
+
const orig = Buffer.from("# Title\n\nHello world. Keep this.\n");
|
|
832
|
+
await writeFile(path.join(tmpRoot, "patch1.md"), orig);
|
|
833
|
+
const req = new Request(fileUrl("patch1.md"), {
|
|
834
|
+
method: "PATCH",
|
|
835
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
|
|
836
|
+
body: JSON.stringify({ find: "Hello world", replace: "Goodbye moon" }),
|
|
837
|
+
});
|
|
838
|
+
const res = await filePATCH(req, makeCtx(["patch1.md"]));
|
|
839
|
+
assert.equal(res.status, 200, await res.text());
|
|
840
|
+
const out = await readFile(path.join(tmpRoot, "patch1.md"), "utf-8");
|
|
841
|
+
assert.equal(out, "# Title\n\nGoodbye moon. Keep this.\n");
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("PATCH: missing If-Match → 412", async () => {
|
|
845
|
+
const orig = Buffer.from("alpha beta gamma");
|
|
846
|
+
await writeFile(path.join(tmpRoot, "patch2.txt"), orig);
|
|
847
|
+
const req = new Request(fileUrl("patch2.txt"), {
|
|
848
|
+
method: "PATCH",
|
|
849
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json" },
|
|
850
|
+
body: JSON.stringify({ find: "beta", replace: "BETA" }),
|
|
851
|
+
});
|
|
852
|
+
const res = await filePATCH(req, makeCtx(["patch2.txt"]));
|
|
853
|
+
assert.equal(res.status, 412);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("PATCH: If-Match mismatch → 412", async () => {
|
|
857
|
+
const orig = Buffer.from("alpha beta gamma");
|
|
858
|
+
await writeFile(path.join(tmpRoot, "patch3.txt"), orig);
|
|
859
|
+
const req = new Request(fileUrl("patch3.txt"), {
|
|
860
|
+
method: "PATCH",
|
|
861
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": "sha256:deadbeef" },
|
|
862
|
+
body: JSON.stringify({ find: "beta", replace: "BETA" }),
|
|
863
|
+
});
|
|
864
|
+
const res = await filePATCH(req, makeCtx(["patch3.txt"]));
|
|
865
|
+
assert.equal(res.status, 412);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("PATCH: zero matches → 422 MATCH_COUNT_MISMATCH", async () => {
|
|
869
|
+
const orig = Buffer.from("alpha beta gamma");
|
|
870
|
+
await writeFile(path.join(tmpRoot, "patch4.txt"), orig);
|
|
871
|
+
const req = new Request(fileUrl("patch4.txt"), {
|
|
872
|
+
method: "PATCH",
|
|
873
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
|
|
874
|
+
body: JSON.stringify({ find: "nonexistent", replace: "x" }),
|
|
875
|
+
});
|
|
876
|
+
const res = await filePATCH(req, makeCtx(["patch4.txt"]));
|
|
877
|
+
assert.equal(res.status, 422);
|
|
878
|
+
const j = await res.json();
|
|
879
|
+
assert.equal(j.error, "MATCH_COUNT_MISMATCH");
|
|
880
|
+
assert.equal(j.found, 0);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("PATCH: multiple matches but expected 1 → 422", async () => {
|
|
884
|
+
const orig = Buffer.from("foo bar foo baz foo");
|
|
885
|
+
await writeFile(path.join(tmpRoot, "patch5.txt"), orig);
|
|
886
|
+
const req = new Request(fileUrl("patch5.txt"), {
|
|
887
|
+
method: "PATCH",
|
|
888
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
|
|
889
|
+
body: JSON.stringify({ find: "foo", replace: "X" }),
|
|
890
|
+
});
|
|
891
|
+
const res = await filePATCH(req, makeCtx(["patch5.txt"]));
|
|
892
|
+
assert.equal(res.status, 422);
|
|
893
|
+
const j = await res.json();
|
|
894
|
+
assert.equal(j.found, 3);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test("PATCH: expectedOccurrences matching multi replaces all", async () => {
|
|
898
|
+
const orig = Buffer.from("foo bar foo baz foo");
|
|
899
|
+
await writeFile(path.join(tmpRoot, "patch6.txt"), orig);
|
|
900
|
+
const req = new Request(fileUrl("patch6.txt"), {
|
|
901
|
+
method: "PATCH",
|
|
902
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
|
|
903
|
+
body: JSON.stringify({ find: "foo", replace: "X", expectedOccurrences: 3 }),
|
|
904
|
+
});
|
|
905
|
+
const res = await filePATCH(req, makeCtx(["patch6.txt"]));
|
|
906
|
+
assert.equal(res.status, 200, await res.text());
|
|
907
|
+
const out = await readFile(path.join(tmpRoot, "patch6.txt"), "utf-8");
|
|
908
|
+
assert.equal(out, "X bar X baz X");
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("PATCH: empty find → 400", async () => {
|
|
912
|
+
const orig = Buffer.from("abc");
|
|
913
|
+
await writeFile(path.join(tmpRoot, "patch7.txt"), orig);
|
|
914
|
+
const req = new Request(fileUrl("patch7.txt"), {
|
|
915
|
+
method: "PATCH",
|
|
916
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
|
|
917
|
+
body: JSON.stringify({ find: "", replace: "x" }),
|
|
918
|
+
});
|
|
919
|
+
const res = await filePATCH(req, makeCtx(["patch7.txt"]));
|
|
920
|
+
assert.equal(res.status, 400);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
test("PATCH: binary file → 415", async () => {
|
|
924
|
+
const orig = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x00, 0x10]);
|
|
925
|
+
await writeFile(path.join(tmpRoot, "patch8.bin"), orig);
|
|
926
|
+
const req = new Request(fileUrl("patch8.bin"), {
|
|
927
|
+
method: "PATCH",
|
|
928
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
|
|
929
|
+
body: JSON.stringify({ find: "x", replace: "y" }),
|
|
930
|
+
});
|
|
931
|
+
const res = await filePATCH(req, makeCtx(["patch8.bin"]));
|
|
932
|
+
assert.equal(res.status, 415);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("PATCH: nonexistent file → 404 (no create)", async () => {
|
|
936
|
+
const req = new Request(fileUrl("patch-missing.txt"), {
|
|
937
|
+
method: "PATCH",
|
|
938
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": "sha256:abc" },
|
|
939
|
+
body: JSON.stringify({ find: "a", replace: "b" }),
|
|
940
|
+
});
|
|
941
|
+
const res = await filePATCH(req, makeCtx(["patch-missing.txt"]));
|
|
942
|
+
assert.equal(res.status, 404);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test("PATCH: .md emits file.rawWritten + reconciles (same path as PUT)", async () => {
|
|
946
|
+
const orig = Buffer.from("# H\n\noriginal paragraph here\n");
|
|
947
|
+
await writeFile(path.join(tmpRoot, "patch-md.md"), orig);
|
|
948
|
+
const req = new Request(fileUrl("patch-md.md"), {
|
|
949
|
+
method: "PATCH",
|
|
950
|
+
headers: { ...agentHeaders(MUTATE_TOKEN, "ai:mutate-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
|
|
951
|
+
body: JSON.stringify({ find: "original", replace: "updated" }),
|
|
952
|
+
});
|
|
953
|
+
const res = await filePATCH(req, makeCtx(["patch-md.md"]));
|
|
954
|
+
assert.equal(res.status, 200, await res.text());
|
|
955
|
+
const sidecar = await readSidecar(tmpRoot, "patch-md.md");
|
|
956
|
+
assert.ok(sidecar, "sidecar created");
|
|
957
|
+
const events = sidecar!.events.filter((e) => e.type === "file.rawWritten");
|
|
958
|
+
assert.ok(events.length > 0, "file.rawWritten event present");
|
|
959
|
+
const newContent = await readFile(path.join(tmpRoot, "patch-md.md"), "utf-8");
|
|
960
|
+
assert.equal(sidecar!.fingerprint, sha256(Buffer.from(newContent)), "fingerprint matches new content (eager reconcile)");
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("PATCH: requires mutate scope", async () => {
|
|
964
|
+
const orig = Buffer.from("hello");
|
|
965
|
+
await writeFile(path.join(tmpRoot, "patch-scope.txt"), orig);
|
|
966
|
+
const req = new Request(fileUrl("patch-scope.txt"), {
|
|
967
|
+
method: "PATCH",
|
|
968
|
+
headers: { ...agentHeaders(READ_TOKEN, "ai:read-agent"), "Content-Type": "application/json", "If-Match": sha256(orig) },
|
|
969
|
+
body: JSON.stringify({ find: "hello", replace: "hi" }),
|
|
970
|
+
});
|
|
971
|
+
const res = await filePATCH(req, makeCtx(["patch-scope.txt"]));
|
|
972
|
+
assert.equal(res.status, 403);
|
|
973
|
+
});
|