openwriter 0.36.3 → 0.37.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/dist/client/assets/index-BBEdpqBq.js +215 -0
- package/dist/client/assets/{index-CQTcQ6xr.css → index-Dz0iuWDM.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/attribution.js +330 -0
- package/dist/server/commits.js +215 -0
- package/dist/server/connections.js +33 -1
- package/dist/server/image-upload.js +164 -2
- package/dist/server/index.js +212 -1
- package/dist/server/mcp.js +58 -8
- package/dist/server/plugin-manager.js +34 -2
- package/dist/server/state.js +125 -9
- package/dist/server/versions.js +31 -8
- package/dist/server/workspaces.js +38 -2
- package/dist/server/ws.js +22 -3
- package/package.json +1 -1
- package/dist/client/assets/index-BaXu2PtF.js +0 -215
|
@@ -7,9 +7,151 @@ import multer from 'multer';
|
|
|
7
7
|
import { existsSync, mkdirSync } from 'fs';
|
|
8
8
|
import { join, extname } from 'path';
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
|
+
import { isIP } from 'net';
|
|
11
|
+
import { lookup } from 'dns/promises';
|
|
10
12
|
import { getDataDir, ensureDataDir } from './helpers.js';
|
|
11
13
|
import express from 'express';
|
|
12
14
|
function getImagesDir() { return join(getDataDir(), '_images'); }
|
|
15
|
+
// ── SSRF guard for /api/download-image (MCP-8) ──────────────────────────────
|
|
16
|
+
// The endpoint server-side-fetches a caller-supplied URL. Without a guard an
|
|
17
|
+
// attacker can point it at internal services, the cloud metadata endpoint
|
|
18
|
+
// (169.254.169.254), or loopback — a classic SSRF. We allow only https, block
|
|
19
|
+
// every private / loopback / link-local / reserved IP range (resolving DNS
|
|
20
|
+
// first), follow redirects manually with re-validation at each hop, and cap
|
|
21
|
+
// both response size and request time.
|
|
22
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the upload limit
|
|
23
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
24
|
+
const MAX_REDIRECTS = 3;
|
|
25
|
+
function isBlockedIpv4(ip) {
|
|
26
|
+
const parts = ip.split('.').map(Number);
|
|
27
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255))
|
|
28
|
+
return true;
|
|
29
|
+
const [a, b] = parts;
|
|
30
|
+
if (a === 0)
|
|
31
|
+
return true; // 0.0.0.0/8 "this host"
|
|
32
|
+
if (a === 10)
|
|
33
|
+
return true; // 10.0.0.0/8 private
|
|
34
|
+
if (a === 127)
|
|
35
|
+
return true; // 127.0.0.0/8 loopback
|
|
36
|
+
if (a === 169 && b === 254)
|
|
37
|
+
return true; // 169.254.0.0/16 link-local (incl. metadata 169.254.169.254)
|
|
38
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
39
|
+
return true; // 172.16.0.0/12 private
|
|
40
|
+
if (a === 192 && b === 168)
|
|
41
|
+
return true; // 192.168.0.0/16 private
|
|
42
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
43
|
+
return true; // 100.64.0.0/10 CGNAT
|
|
44
|
+
if (a >= 224)
|
|
45
|
+
return true; // 224.0.0.0/4 multicast + 240/4 reserved + 255 broadcast
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function isBlockedIpv6(ip) {
|
|
49
|
+
const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
|
|
50
|
+
// IPv4-mapped (::ffff:a.b.c.d) — classify on the embedded v4 address.
|
|
51
|
+
const mapped = s.match(/(?:^|:)((?:\d{1,3}\.){3}\d{1,3})$/);
|
|
52
|
+
if (mapped)
|
|
53
|
+
return isBlockedIpv4(mapped[1]);
|
|
54
|
+
if (s === '::1' || s === '::')
|
|
55
|
+
return true; // loopback / unspecified
|
|
56
|
+
if (s.startsWith('fe8') || s.startsWith('fe9') || s.startsWith('fea') || s.startsWith('feb'))
|
|
57
|
+
return true; // fe80::/10 link-local
|
|
58
|
+
if (s.startsWith('fc') || s.startsWith('fd'))
|
|
59
|
+
return true; // fc00::/7 unique-local
|
|
60
|
+
if (s.startsWith('ff'))
|
|
61
|
+
return true; // ff00::/8 multicast
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
function isBlockedAddress(ip) {
|
|
65
|
+
const fam = isIP(ip);
|
|
66
|
+
if (fam === 4)
|
|
67
|
+
return isBlockedIpv4(ip);
|
|
68
|
+
if (fam === 6)
|
|
69
|
+
return isBlockedIpv6(ip);
|
|
70
|
+
return true; // not a recognizable IP → refuse
|
|
71
|
+
}
|
|
72
|
+
/** Throw unless `rawUrl` is an https URL whose host resolves only to public addresses. */
|
|
73
|
+
async function assertSafeImageUrl(rawUrl) {
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = new URL(rawUrl);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new Error('blocked');
|
|
80
|
+
}
|
|
81
|
+
if (parsed.protocol !== 'https:')
|
|
82
|
+
throw new Error('blocked');
|
|
83
|
+
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
84
|
+
if (isIP(host)) {
|
|
85
|
+
if (isBlockedAddress(host))
|
|
86
|
+
throw new Error('blocked');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let addrs;
|
|
90
|
+
try {
|
|
91
|
+
addrs = await lookup(host, { all: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error('blocked');
|
|
95
|
+
}
|
|
96
|
+
if (addrs.length === 0)
|
|
97
|
+
throw new Error('blocked');
|
|
98
|
+
for (const a of addrs) {
|
|
99
|
+
if (isBlockedAddress(a.address))
|
|
100
|
+
throw new Error('blocked');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Fetch an image with SSRF guards: https-only, public-IP-only, manual
|
|
104
|
+
* redirect re-validation, size cap, and timeout. Returns the response for a
|
|
105
|
+
* caller to read (the caller still enforces the byte cap while streaming). */
|
|
106
|
+
async function safeImageFetch(initialUrl) {
|
|
107
|
+
let current = initialUrl;
|
|
108
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
109
|
+
await assertSafeImageUrl(current);
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
112
|
+
let response;
|
|
113
|
+
try {
|
|
114
|
+
response = await fetch(current, { redirect: 'manual', signal: controller.signal });
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
if (response.status >= 300 && response.status < 400) {
|
|
120
|
+
const location = response.headers.get('location');
|
|
121
|
+
if (!location)
|
|
122
|
+
return response;
|
|
123
|
+
current = new URL(location, current).toString(); // re-validated at loop top
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
return response;
|
|
127
|
+
}
|
|
128
|
+
throw new Error('blocked');
|
|
129
|
+
}
|
|
130
|
+
/** Read a response body into a Buffer, aborting (returns null) once `max` bytes
|
|
131
|
+
* are exceeded so a server that lies about content-length can't blow past the cap. */
|
|
132
|
+
async function readCapped(response, max) {
|
|
133
|
+
if (!response.body) {
|
|
134
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
135
|
+
return buf.length > max ? null : buf;
|
|
136
|
+
}
|
|
137
|
+
const reader = response.body.getReader();
|
|
138
|
+
const chunks = [];
|
|
139
|
+
let total = 0;
|
|
140
|
+
while (true) {
|
|
141
|
+
const { done, value } = await reader.read();
|
|
142
|
+
if (done)
|
|
143
|
+
break;
|
|
144
|
+
if (value) {
|
|
145
|
+
total += value.length;
|
|
146
|
+
if (total > max) {
|
|
147
|
+
await reader.cancel().catch(() => { });
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
chunks.push(value);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return Buffer.concat(chunks);
|
|
154
|
+
}
|
|
13
155
|
function ensureImagesDir() {
|
|
14
156
|
ensureDataDir();
|
|
15
157
|
const dir = getImagesDir();
|
|
@@ -61,8 +203,17 @@ export function createImageRouter() {
|
|
|
61
203
|
res.status(400).json({ error: 'No URL provided' });
|
|
62
204
|
return;
|
|
63
205
|
}
|
|
206
|
+
let response;
|
|
207
|
+
try {
|
|
208
|
+
response = await safeImageFetch(url);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// SSRF guard rejected the URL (bad scheme, private/blocked host, too many
|
|
212
|
+
// redirects). Generic message — never echo the resolved host or reason.
|
|
213
|
+
res.status(400).json({ error: 'URL not allowed' });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
64
216
|
try {
|
|
65
|
-
const response = await fetch(url);
|
|
66
217
|
if (!response.ok) {
|
|
67
218
|
res.status(400).json({ error: 'Failed to fetch image' });
|
|
68
219
|
return;
|
|
@@ -72,14 +223,25 @@ export function createImageRouter() {
|
|
|
72
223
|
res.status(400).json({ error: 'URL is not an image' });
|
|
73
224
|
return;
|
|
74
225
|
}
|
|
226
|
+
// Reject early if the server declares an over-limit size.
|
|
227
|
+
const declaredLen = Number(response.headers.get('content-length'));
|
|
228
|
+
if (Number.isFinite(declaredLen) && declaredLen > MAX_IMAGE_BYTES) {
|
|
229
|
+
res.status(400).json({ error: 'Image too large' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
75
232
|
const ext = contentType.includes('jpeg') || contentType.includes('jpg') ? '.jpg'
|
|
76
233
|
: contentType.includes('gif') ? '.gif'
|
|
77
234
|
: contentType.includes('webp') ? '.webp'
|
|
78
235
|
: '.png';
|
|
236
|
+
// Stream with a hard byte cap so a lying/streaming server can't exhaust disk.
|
|
237
|
+
const buffer = await readCapped(response, MAX_IMAGE_BYTES);
|
|
238
|
+
if (!buffer) {
|
|
239
|
+
res.status(400).json({ error: 'Image too large' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
79
242
|
ensureImagesDir();
|
|
80
243
|
const filename = `${randomUUID().slice(0, 8)}${ext}`;
|
|
81
244
|
const filePath = join(getImagesDir(), filename);
|
|
82
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
83
245
|
const { writeFileSync } = await import('fs');
|
|
84
246
|
writeFileSync(filePath, buffer);
|
|
85
247
|
const src = `/_images/${filename}`;
|
package/dist/server/index.js
CHANGED
|
@@ -45,6 +45,109 @@ const __dirname = dirname(__filename);
|
|
|
45
45
|
let runtimePort = 5050;
|
|
46
46
|
export function getRuntimePort() { return runtimePort; }
|
|
47
47
|
export function getBaseUrl() { return `http://localhost:${runtimePort}`; }
|
|
48
|
+
// ---- Trust boundary (anti-DNS-rebinding + CSRF) — MCP-5 ----
|
|
49
|
+
// The HTTP API binds to loopback and historically trusted "localhost = the
|
|
50
|
+
// user." That is false: with no Host/Origin validation a remote website can
|
|
51
|
+
// reach this API via DNS rebinding (its page rebinds a hostname to 127.0.0.1,
|
|
52
|
+
// the browser keeps sending the attacker's Host/Origin) and drive every
|
|
53
|
+
// mutating route — switch profiles, enable plugins, rewrite plugin config.
|
|
54
|
+
// The middleware below is the single gate for ALL routes. adr: see MCP-5.
|
|
55
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
56
|
+
/** Split a Host header into hostname + optional port, handling [::1]:port. */
|
|
57
|
+
function splitHostHeader(hostHeader) {
|
|
58
|
+
if (hostHeader.startsWith('[')) {
|
|
59
|
+
const close = hostHeader.indexOf(']');
|
|
60
|
+
if (close === -1)
|
|
61
|
+
return { host: hostHeader };
|
|
62
|
+
const host = hostHeader.slice(1, close);
|
|
63
|
+
const rest = hostHeader.slice(close + 1);
|
|
64
|
+
return { host, port: rest.startsWith(':') ? rest.slice(1) : undefined };
|
|
65
|
+
}
|
|
66
|
+
const colon = hostHeader.lastIndexOf(':');
|
|
67
|
+
if (colon === -1)
|
|
68
|
+
return { host: hostHeader };
|
|
69
|
+
return { host: hostHeader.slice(0, colon), port: hostHeader.slice(colon + 1) };
|
|
70
|
+
}
|
|
71
|
+
/** True when the Host header names loopback on the port we are serving. */
|
|
72
|
+
function isAllowedHost(hostHeader, port) {
|
|
73
|
+
if (!hostHeader)
|
|
74
|
+
return false;
|
|
75
|
+
const { host, port: p } = splitHostHeader(hostHeader.trim());
|
|
76
|
+
if (!LOOPBACK_HOSTS.has(host.toLowerCase()))
|
|
77
|
+
return false;
|
|
78
|
+
if (p !== undefined && p !== String(port))
|
|
79
|
+
return false;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
/** True when an Origin/Referer URL is same-origin (loopback on our port). */
|
|
83
|
+
function isAllowedOrigin(value, port) {
|
|
84
|
+
if (!value)
|
|
85
|
+
return false;
|
|
86
|
+
try {
|
|
87
|
+
const u = new URL(value);
|
|
88
|
+
if (!LOOPBACK_HOSTS.has(u.hostname.toLowerCase()))
|
|
89
|
+
return false;
|
|
90
|
+
if (u.port && u.port !== String(port))
|
|
91
|
+
return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
99
|
+
/**
|
|
100
|
+
* Global security gate, applied before any route or body parsing.
|
|
101
|
+
* (a) Host allowlist — the anti-DNS-rebinding control.
|
|
102
|
+
* (b) Origin/Referer same-origin check on state-changing methods (CSRF).
|
|
103
|
+
* Browsers always send Origin on cross-origin POST, so a rebound page is
|
|
104
|
+
* rejected here too. A *missing* Origin is allowed only for state-changing
|
|
105
|
+
* requests from non-browser local clients (the client-mode MCP-over-HTTP
|
|
106
|
+
* proxy uses curl-style requests with no Origin); the Host gate still
|
|
107
|
+
* bounds those to loopback.
|
|
108
|
+
* (c) Restrictive security headers on every response.
|
|
109
|
+
*/
|
|
110
|
+
function securityGate(port) {
|
|
111
|
+
return (req, res, next) => {
|
|
112
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
113
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
114
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
115
|
+
res.setHeader('Content-Security-Policy', [
|
|
116
|
+
"default-src 'self'",
|
|
117
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
118
|
+
"style-src 'self' 'unsafe-inline'",
|
|
119
|
+
"img-src 'self' data: blob: https:",
|
|
120
|
+
"font-src 'self' data:",
|
|
121
|
+
"connect-src 'self' ws: wss:",
|
|
122
|
+
"frame-ancestors 'none'",
|
|
123
|
+
"base-uri 'self'",
|
|
124
|
+
"object-src 'none'",
|
|
125
|
+
].join('; '));
|
|
126
|
+
if (!isAllowedHost(req.headers.host, port)) {
|
|
127
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (STATE_CHANGING.has(req.method)) {
|
|
131
|
+
const origin = req.headers.origin;
|
|
132
|
+
const referer = req.headers.referer;
|
|
133
|
+
if (origin) {
|
|
134
|
+
if (!isAllowedOrigin(origin, port)) {
|
|
135
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (referer) {
|
|
140
|
+
if (!isAllowedOrigin(referer, port)) {
|
|
141
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// No Origin and no Referer: non-browser local client; Host gate above
|
|
146
|
+
// already constrained it to loopback. Allow.
|
|
147
|
+
}
|
|
148
|
+
next();
|
|
149
|
+
};
|
|
150
|
+
}
|
|
48
151
|
export async function startHttpServer(options = {}) {
|
|
49
152
|
const port = options.port || 5050;
|
|
50
153
|
runtimePort = port;
|
|
@@ -55,6 +158,11 @@ export async function startHttpServer(options = {}) {
|
|
|
55
158
|
initLogger();
|
|
56
159
|
logger.info('state', 'server-boot', `OpenWriter starting on port ${port}`, { port });
|
|
57
160
|
const app = express();
|
|
161
|
+
// Trust boundary FIRST — reject cross-host / cross-origin before body
|
|
162
|
+
// parsing or any route handler runs. Covers every route, including the
|
|
163
|
+
// unauth state-changers /api/profiles/switch, /api/plugins/enable,
|
|
164
|
+
// /api/plugins/config, and the universal /api/mcp-call dispatcher. MCP-5.
|
|
165
|
+
app.use(securityGate(port));
|
|
58
166
|
app.use(express.json({ limit: '10mb' }));
|
|
59
167
|
// API routes for direct HTTP access (fallback if WS not available)
|
|
60
168
|
app.get('/api/status', (_req, res) => {
|
|
@@ -69,7 +177,13 @@ export async function startHttpServer(options = {}) {
|
|
|
69
177
|
}));
|
|
70
178
|
res.json({ tools });
|
|
71
179
|
});
|
|
72
|
-
// MCP-over-HTTP: allows client-mode terminals to proxy tool calls
|
|
180
|
+
// MCP-over-HTTP: allows client-mode terminals to proxy tool calls.
|
|
181
|
+
// MCP-4: this dispatches ANY MCP tool with the user's platform credentials,
|
|
182
|
+
// so it MUST never be reachable cross-origin. That guarantee is provided by
|
|
183
|
+
// the global securityGate above (Host allowlist + Origin/Referer check on
|
|
184
|
+
// POST) — a DNS-rebound page cannot satisfy either. No tool is intentionally
|
|
185
|
+
// exposed beyond same-origin/local callers here; any future tool that must
|
|
186
|
+
// never be driven over HTTP should be denylisted at this entry point.
|
|
73
187
|
app.post('/api/mcp-call', async (req, res) => {
|
|
74
188
|
const { tool: toolName, arguments: args } = req.body;
|
|
75
189
|
// Wrap the call in a request ID scope so every event logged during
|
|
@@ -480,6 +594,103 @@ export async function startHttpServer(options = {}) {
|
|
|
480
594
|
res.status(500).json({ error: err.message });
|
|
481
595
|
}
|
|
482
596
|
});
|
|
597
|
+
// Author attribution (voice-shape heatmap data). Returns char-weighted
|
|
598
|
+
// composition + per-node origin (human|agent|mixed|unknown) for a doc.
|
|
599
|
+
// The heatmap colours the live editor by nodeOrigins; the header shows percent.
|
|
600
|
+
// adr: adr/document-history-attribution.md
|
|
601
|
+
app.get('/api/attribution/:docId', async (req, res) => {
|
|
602
|
+
try {
|
|
603
|
+
const { docId } = req.params;
|
|
604
|
+
const { readBlame, summarizeBlame } = await import('./attribution.js');
|
|
605
|
+
const { tiptapToBlocks } = await import('./node-blocks.js');
|
|
606
|
+
let doc = null;
|
|
607
|
+
if (getDocId() === docId) {
|
|
608
|
+
doc = getDocument();
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
const { filenameByDocId } = await import('./documents.js');
|
|
612
|
+
const { loadDocFromDisk } = await import('./pending-overlay.js');
|
|
613
|
+
const fn = filenameByDocId(docId);
|
|
614
|
+
if (fn) {
|
|
615
|
+
try {
|
|
616
|
+
doc = loadDocFromDisk(fn).document;
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
doc = null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const blame = readBlame(docId);
|
|
624
|
+
if (!doc) {
|
|
625
|
+
res.json({ tracked: blame !== null, percent: { human: 0, agent: 0, unknown: 0 }, chars: { human: 0, agent: 0, unknown: 0 }, nodeOrigins: {}, attributionSince: blame?.attributionSince ?? null });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const summary = summarizeBlame(blame, tiptapToBlocks(doc));
|
|
629
|
+
res.json({ tracked: blame !== null, percent: summary.percent, chars: summary.chars, nodeOrigins: summary.nodes, attributionSince: blame?.attributionSince ?? null });
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
res.status(500).json({ error: err.message });
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
// Version commits — the attributed git-history for a doc (newest first), each
|
|
636
|
+
// with a one-line changeset label. adr: adr/document-history-attribution.md
|
|
637
|
+
app.get('/api/commits/:docId', async (req, res) => {
|
|
638
|
+
try {
|
|
639
|
+
const { listCommits, summaryLine, commitSnapshotAvailable } = await import('./commits.js');
|
|
640
|
+
const commits = listCommits(req.params.docId)
|
|
641
|
+
.map((c) => ({ ...c, label: summaryLine(c.summary), restorable: commitSnapshotAvailable(req.params.docId, c.ts) }))
|
|
642
|
+
.reverse(); // newest first for the panel
|
|
643
|
+
res.json({ commits });
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
res.status(500).json({ error: err.message });
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
// One commit's attributed change detail (the per-event diff data the panel
|
|
650
|
+
// renders when a commit row is expanded).
|
|
651
|
+
app.get('/api/commit-detail/:docId/:ts', async (req, res) => {
|
|
652
|
+
try {
|
|
653
|
+
const { getCommitDetail } = await import('./commits.js');
|
|
654
|
+
const detail = getCommitDetail(req.params.docId, Number(req.params.ts));
|
|
655
|
+
if (!detail) {
|
|
656
|
+
res.status(404).json({ error: 'commit not found' });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
res.json(detail);
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
res.status(500).json({ error: err.message });
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
// Manual "Save version" — create a commit now with an optional note. The
|
|
666
|
+
// changeset is whatever attributed edits have accrued since the last commit.
|
|
667
|
+
app.post('/api/commit', async (req, res) => {
|
|
668
|
+
try {
|
|
669
|
+
const { docId, note } = req.body || {};
|
|
670
|
+
if (!docId || typeof docId !== 'string') {
|
|
671
|
+
res.status(400).json({ error: 'docId required' });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
// Flush the active doc so its latest edits are on disk before we snapshot.
|
|
675
|
+
if (getDocId() === docId) {
|
|
676
|
+
try {
|
|
677
|
+
save();
|
|
678
|
+
}
|
|
679
|
+
catch { /* best-effort */ }
|
|
680
|
+
}
|
|
681
|
+
const { commitFromFile } = await import('./commits.js');
|
|
682
|
+
const { filenameByDocId } = await import('./documents.js');
|
|
683
|
+
const { resolveDocPath } = await import('./helpers.js');
|
|
684
|
+
const fn = filenameByDocId(docId);
|
|
685
|
+
const commit = fn
|
|
686
|
+
? commitFromFile(docId, resolveDocPath(fn), { trigger: 'manual', actor: 'human', note: typeof note === 'string' ? note : undefined, nowTs: Date.now() })
|
|
687
|
+
: null;
|
|
688
|
+
res.json({ committed: commit !== null, commit });
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
res.status(500).json({ error: err.message });
|
|
692
|
+
}
|
|
693
|
+
});
|
|
483
694
|
// References: full rebuild across all docs (idempotent rescue path).
|
|
484
695
|
// Walks every .md, extracts legacy prose `doc:` links from body, merges
|
|
485
696
|
// their targets into `references:`, strips any legacy `backlinks:` field.
|
package/dist/server/mcp.js
CHANGED
|
@@ -12,6 +12,7 @@ import { z } from 'zod';
|
|
|
12
12
|
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync, readConfig } from './helpers.js';
|
|
13
13
|
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, getIsTemp, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
|
+
import { readBlame, summarizeBlame } from './attribution.js';
|
|
15
16
|
import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
|
|
16
17
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
17
18
|
import { resolveTypeMeta } from './content-type-meta.js';
|
|
@@ -200,6 +201,13 @@ const READ_PAD_MAX_WORDS = 2000;
|
|
|
200
201
|
* learns the new behavior without repeating the explanation. Resets on
|
|
201
202
|
* server restart. */
|
|
202
203
|
let firstTruncationShown = false;
|
|
204
|
+
/** MCP-9: metadata keys an agent must NEVER set via set_metadata. `autoAccept`
|
|
205
|
+
* governs the human accept/reject gate — letting the agent write it via
|
|
206
|
+
* open-ended frontmatter would self-grant auto-accept and bypass human review.
|
|
207
|
+
* These are operator-only (set through the UI toggle path). The metadata
|
|
208
|
+
* surface is otherwise intentionally open-ended, so this is a denylist of the
|
|
209
|
+
* finite, enumerable privileged keys rather than an allowlist of content keys. */
|
|
210
|
+
const AGENT_FORBIDDEN_METADATA_KEYS = new Set(['autoAccept']);
|
|
203
211
|
export const TOOL_REGISTRY = [
|
|
204
212
|
{
|
|
205
213
|
name: 'read_pad',
|
|
@@ -551,7 +559,7 @@ export const TOOL_REGISTRY = [
|
|
|
551
559
|
wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
|
|
552
560
|
}
|
|
553
561
|
const newDocId = getDocId();
|
|
554
|
-
save();
|
|
562
|
+
save('agent');
|
|
555
563
|
broadcastDocumentsChanged();
|
|
556
564
|
broadcastWorkspacesChanged();
|
|
557
565
|
broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
|
|
@@ -692,7 +700,7 @@ export const TOOL_REGISTRY = [
|
|
|
692
700
|
}
|
|
693
701
|
updateDocument(doc);
|
|
694
702
|
updatePendingCacheForActiveDoc();
|
|
695
|
-
save();
|
|
703
|
+
save('agent');
|
|
696
704
|
// Broadcast sidebar updates first (deferred from create_document) so the doc
|
|
697
705
|
// entry and spinner removal arrive in the same render cycle
|
|
698
706
|
broadcastDocumentsChanged();
|
|
@@ -880,6 +888,31 @@ export const TOOL_REGISTRY = [
|
|
|
880
888
|
return { content: [{ type: 'text', text: Object.keys(target.metadata).length > 0 ? JSON.stringify(target.metadata) : '{}' }] };
|
|
881
889
|
},
|
|
882
890
|
},
|
|
891
|
+
{
|
|
892
|
+
name: 'get_attribution',
|
|
893
|
+
description: 'Get human-vs-agent author attribution for a document. Returns the char-weighted composition (% human / % agent / % unknown) plus per-node coarse origin (human | agent | mixed | unknown). Attribution is captured automatically at save time and anchored to sentence content, so it survives edits, splits, and paste-back. "unknown" = content authored before attribution tracking began. Use to report how much of a doc is genuinely author-written vs agent-scaffolded.',
|
|
894
|
+
schema: {
|
|
895
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
896
|
+
},
|
|
897
|
+
handler: async ({ docId }) => {
|
|
898
|
+
const target = resolveDocTarget(docId);
|
|
899
|
+
const blocks = tiptapToBlocks(target.document);
|
|
900
|
+
const blame = readBlame(docId);
|
|
901
|
+
const summary = summarizeBlame(blame, blocks);
|
|
902
|
+
const nodeCounts = { human: 0, agent: 0, mixed: 0, unknown: 0 };
|
|
903
|
+
for (const origin of Object.values(summary.nodes))
|
|
904
|
+
nodeCounts[origin] = (nodeCounts[origin] ?? 0) + 1;
|
|
905
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
906
|
+
docId,
|
|
907
|
+
percent: summary.percent,
|
|
908
|
+
chars: summary.chars,
|
|
909
|
+
nodeOrigins: summary.nodes,
|
|
910
|
+
nodeCounts,
|
|
911
|
+
tracked: blame !== null,
|
|
912
|
+
attributionSince: blame?.attributionSince ?? null,
|
|
913
|
+
}) }] };
|
|
914
|
+
},
|
|
915
|
+
},
|
|
883
916
|
{
|
|
884
917
|
name: 'set_metadata',
|
|
885
918
|
description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately. Lifecycle convention (v0.19.0): use `set_metadata({ status: "canonical" })` when a doc commits to the workspace spine (Beats locks, Research Note becomes load-bearing); use `set_metadata({ status: "draft" })` when a doc is superseded or demoted. Status is the agent\'s field — the enrichment minion never writes it.',
|
|
@@ -887,8 +920,23 @@ export const TOOL_REGISTRY = [
|
|
|
887
920
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
888
921
|
metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
|
|
889
922
|
},
|
|
890
|
-
handler: async ({ docId, metadata:
|
|
923
|
+
handler: async ({ docId, metadata: rawUpdates }) => {
|
|
891
924
|
const target = resolveDocTarget(docId);
|
|
925
|
+
// MCP-9: strip control keys. `autoAccept` governs the human accept/reject
|
|
926
|
+
// gate — an agent that could set it via set_metadata would self-grant
|
|
927
|
+
// auto-accept and bypass human review entirely. The approval-mode flag is
|
|
928
|
+
// operator-only (UI toggle → setDocAutoAccept / setWorkspaceAutoAccept).
|
|
929
|
+
// Stripping covers both set AND remove: deleting an explicit
|
|
930
|
+
// `autoAccept: false` would re-enable workspace-inherited auto-accept.
|
|
931
|
+
const updates = {};
|
|
932
|
+
const blockedKeys = [];
|
|
933
|
+
for (const [key, value] of Object.entries(rawUpdates)) {
|
|
934
|
+
if (AGENT_FORBIDDEN_METADATA_KEYS.has(key)) {
|
|
935
|
+
blockedKeys.push(key);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
updates[key] = value;
|
|
939
|
+
}
|
|
892
940
|
const setKeys = [];
|
|
893
941
|
const removed = [];
|
|
894
942
|
for (const [key, value] of Object.entries(updates)) {
|
|
@@ -923,7 +971,7 @@ export const TOOL_REGISTRY = [
|
|
|
923
971
|
const meta = getMetadata();
|
|
924
972
|
for (const key of removed)
|
|
925
973
|
delete meta[key];
|
|
926
|
-
save();
|
|
974
|
+
save('agent');
|
|
927
975
|
broadcastMetadataChanged(getMetadata());
|
|
928
976
|
if (cleaned.title) {
|
|
929
977
|
// Reached only on temp-file creation titling — hot promote.
|
|
@@ -962,6 +1010,8 @@ export const TOOL_REGISTRY = [
|
|
|
962
1010
|
parts.push(`set: ${keys.join(', ')}`);
|
|
963
1011
|
if (removed.length > 0)
|
|
964
1012
|
parts.push(`removed: ${removed.join(', ')}`);
|
|
1013
|
+
if (blockedKeys.length > 0)
|
|
1014
|
+
parts.push(`ignored (operator-only): ${blockedKeys.join(', ')}`);
|
|
965
1015
|
return { content: [{ type: 'text', text: `Metadata updated (${parts.join('; ')})` }] };
|
|
966
1016
|
},
|
|
967
1017
|
},
|
|
@@ -1015,7 +1065,7 @@ export const TOOL_REGISTRY = [
|
|
|
1015
1065
|
for (const k of LEGACY_FIELDS_TO_RETIRE)
|
|
1016
1066
|
delete liveMeta[k];
|
|
1017
1067
|
bumpDocVersion();
|
|
1018
|
-
save();
|
|
1068
|
+
save('agent');
|
|
1019
1069
|
broadcastMetadataChanged(getMetadata());
|
|
1020
1070
|
}
|
|
1021
1071
|
else {
|
|
@@ -1647,7 +1697,7 @@ export const TOOL_REGISTRY = [
|
|
|
1647
1697
|
doc.content.push(pendingImage);
|
|
1648
1698
|
}
|
|
1649
1699
|
updateDocument(doc);
|
|
1650
|
-
save();
|
|
1700
|
+
save('agent');
|
|
1651
1701
|
setAgentLockActive();
|
|
1652
1702
|
broadcastDocumentSwitched(doc, getTitle(), getActiveFilename(), getMetadata());
|
|
1653
1703
|
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, lastNodeId: imgId }) }] };
|
|
@@ -1668,7 +1718,7 @@ export const TOOL_REGISTRY = [
|
|
|
1668
1718
|
articleContext.coverImage = src;
|
|
1669
1719
|
articleContext.coverImages = existing;
|
|
1670
1720
|
setMetadata({ articleContext });
|
|
1671
|
-
save();
|
|
1721
|
+
save('agent');
|
|
1672
1722
|
broadcastMetadataChanged(getMetadata());
|
|
1673
1723
|
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: true }) }] };
|
|
1674
1724
|
}
|
|
@@ -1970,7 +2020,7 @@ export const TOOL_REGISTRY = [
|
|
|
1970
2020
|
// Active doc: mutate state.metadata and let save() persist the frontmatter.
|
|
1971
2021
|
// save()'s writeToDisk path invalidates the backlinks cache.
|
|
1972
2022
|
setMetadata({ references: newReferences });
|
|
1973
|
-
save();
|
|
2023
|
+
save('agent');
|
|
1974
2024
|
broadcastMetadataChanged(getMetadata());
|
|
1975
2025
|
}
|
|
1976
2026
|
else {
|
|
@@ -7,6 +7,23 @@ import { discoverPlugins, loadPluginModule } from './plugin-discovery.js';
|
|
|
7
7
|
import { registerPluginTools, removePluginTools } from './mcp.js';
|
|
8
8
|
import { readConfig, saveConfig, getDataDir } from './helpers.js';
|
|
9
9
|
import { broadcastPluginsChanged } from './ws.js';
|
|
10
|
+
import { isAllowedPublishApiUrl } from './connections.js';
|
|
11
|
+
// MCP-2: plugin config holds raw secrets (publish ow_live_ key, X OAuth1
|
|
12
|
+
// tokens, GitHub PAT, Gemini key). These must never cross the HTTP API. We
|
|
13
|
+
// redact any config value whose KEY names a secret before it leaves the
|
|
14
|
+
// server. Returned in place of the value is a sentinel that the settings UI
|
|
15
|
+
// renders as "set"; updateConfig() treats an echoed sentinel as "unchanged"
|
|
16
|
+
// so a naive save round-trip can never clobber the real secret with the mask.
|
|
17
|
+
const SECRET_KEY_RE = /(key|secret|token|pat|password|auth|credential|bearer)/i;
|
|
18
|
+
const REDACTED_SECRET = '__OW_SECRET_REDACTED__';
|
|
19
|
+
/** Mask secret-valued config fields for safe transport over the API. */
|
|
20
|
+
function redactConfig(config) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [k, v] of Object.entries(config)) {
|
|
23
|
+
out[k] = SECRET_KEY_RE.test(k) && v ? REDACTED_SECRET : v;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
10
27
|
export class PluginManager {
|
|
11
28
|
app;
|
|
12
29
|
plugins = new Map();
|
|
@@ -101,7 +118,22 @@ export class PluginManager {
|
|
|
101
118
|
const managed = this.plugins.get(name);
|
|
102
119
|
if (!managed)
|
|
103
120
|
return { success: false, error: `Plugin "${name}" not found` };
|
|
104
|
-
|
|
121
|
+
// Drop echoed redaction sentinels — the API never hands out real secrets
|
|
122
|
+
// (see redactConfig), so a value equal to the sentinel means "unchanged".
|
|
123
|
+
// Keeping the spread merge then preserves the stored secret. MCP-2.
|
|
124
|
+
const incoming = {};
|
|
125
|
+
for (const [k, v] of Object.entries(values)) {
|
|
126
|
+
if (v === REDACTED_SECRET)
|
|
127
|
+
continue;
|
|
128
|
+
incoming[k] = v;
|
|
129
|
+
}
|
|
130
|
+
// MCP-6: a hijacked publish `api-url` redirects the Bearer key off-host.
|
|
131
|
+
// Reject writes that point it anywhere but an allowed destination. The
|
|
132
|
+
// load-bearing pin lives in connections.ts; this rejects bad writes early.
|
|
133
|
+
if (typeof incoming['api-url'] === 'string' && incoming['api-url'] && !isAllowedPublishApiUrl(incoming['api-url'])) {
|
|
134
|
+
return { success: false, error: 'Invalid api-url: must point to an OpenWriter publish host' };
|
|
135
|
+
}
|
|
136
|
+
managed.config = { ...managed.config, ...incoming };
|
|
105
137
|
this.savePluginState();
|
|
106
138
|
return { success: true };
|
|
107
139
|
}
|
|
@@ -113,7 +145,7 @@ export class PluginManager {
|
|
|
113
145
|
description: m.discovered.description,
|
|
114
146
|
enabled: m.enabled,
|
|
115
147
|
configSchema: m.configSchema,
|
|
116
|
-
config: m.config,
|
|
148
|
+
config: redactConfig(m.config), // MCP-2: never leak raw secrets over the API
|
|
117
149
|
source: m.discovered.source,
|
|
118
150
|
displayName: m.discovered.displayName,
|
|
119
151
|
category: m.discovered.category,
|