openwriter 0.37.0 → 0.38.0
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-Dz0iuWDM.css → index-2miZWC8D.css} +1 -1
- package/dist/client/assets/index-C_Zb7mUx.js +215 -0
- package/dist/client/index.html +2 -2
- package/dist/server/connections.js +33 -1
- package/dist/server/content-type-meta.js +1 -0
- package/dist/server/helpers.js +2 -2
- package/dist/server/image-upload.js +164 -2
- package/dist/server/index.js +118 -1
- package/dist/server/logger.js +2 -2
- package/dist/server/manuscript/assemble.js +128 -0
- package/dist/server/manuscript/index.js +35 -0
- package/dist/server/manuscript/load.js +65 -0
- package/dist/server/manuscript/parse.js +59 -0
- package/dist/server/manuscript/render/css.js +41 -0
- package/dist/server/manuscript/render/docx.js +10 -0
- package/dist/server/manuscript/render/epub.js +156 -0
- package/dist/server/manuscript/render/html.js +91 -0
- package/dist/server/manuscript/render/md.js +41 -0
- package/dist/server/manuscript/resolve.js +42 -0
- package/dist/server/manuscript-routes.js +78 -0
- package/dist/server/mcp.js +103 -5
- package/dist/server/plugin-manager.js +34 -2
- package/dist/server/state.js +1 -1
- package/dist/server/workspaces.js +38 -2
- package/package.json +2 -1
- package/dist/client/assets/index-BBEdpqBq.js +0 -215
package/dist/server/mcp.js
CHANGED
|
@@ -9,7 +9,9 @@ import { randomUUID } from 'crypto';
|
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
-
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync, readConfig } from './helpers.js';
|
|
12
|
+
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync, readConfig, ROOT_DIR } from './helpers.js';
|
|
13
|
+
import { compileManuscript, renderBookHtml, renderEpub, renderDocx } from './manuscript/index.js';
|
|
14
|
+
import { loadManifest, safeName } from './manuscript/load.js';
|
|
13
15
|
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
16
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
17
|
import { readBlame, summarizeBlame } from './attribution.js';
|
|
@@ -201,6 +203,13 @@ const READ_PAD_MAX_WORDS = 2000;
|
|
|
201
203
|
* learns the new behavior without repeating the explanation. Resets on
|
|
202
204
|
* server restart. */
|
|
203
205
|
let firstTruncationShown = false;
|
|
206
|
+
/** MCP-9: metadata keys an agent must NEVER set via set_metadata. `autoAccept`
|
|
207
|
+
* governs the human accept/reject gate — letting the agent write it via
|
|
208
|
+
* open-ended frontmatter would self-grant auto-accept and bypass human review.
|
|
209
|
+
* These are operator-only (set through the UI toggle path). The metadata
|
|
210
|
+
* surface is otherwise intentionally open-ended, so this is a denylist of the
|
|
211
|
+
* finite, enumerable privileged keys rather than an allowlist of content keys. */
|
|
212
|
+
const AGENT_FORBIDDEN_METADATA_KEYS = new Set(['autoAccept']);
|
|
204
213
|
export const TOOL_REGISTRY = [
|
|
205
214
|
{
|
|
206
215
|
name: 'read_pad',
|
|
@@ -482,7 +491,7 @@ export const TOOL_REGISTRY = [
|
|
|
482
491
|
workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
|
|
483
492
|
container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
|
|
484
493
|
empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
|
|
485
|
-
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
|
|
494
|
+
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog', 'manuscript']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically. "manuscript" = a binding doc whose body is an ordered list of [text](doc:ID) pointers under ## chapter headings; populate it with the manifest, then it compiles to EPUB/DOCX via the manuscript routes.'),
|
|
486
495
|
url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
|
|
487
496
|
afterId: z.string().optional().describe('Place the new doc immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention: newest at bottom). Requires workspace.'),
|
|
488
497
|
status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle. "canonical" = committed to spine / load-bearing for the workspace (use for Beats docs that have locked, Research Notes, Master References). "draft" = working / not load-bearing yet / scratch (DUMP docs, first-pass beats). Defaults to "draft" when omitted. Change later via set_metadata({ status: ... }) on lifecycle transitions. v0.19.0.'),
|
|
@@ -721,7 +730,7 @@ export const TOOL_REGISTRY = [
|
|
|
721
730
|
schema: {
|
|
722
731
|
writes: z.array(z.object({
|
|
723
732
|
title: z.string().describe('Title for the document.'),
|
|
724
|
-
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Content type. Use "document" for plain docs.'),
|
|
733
|
+
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog', 'manuscript']).describe('Content type. Use "document" for plain docs. "manuscript" = a binding doc of ordered doc: pointers.'),
|
|
725
734
|
workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it does not exist.'),
|
|
726
735
|
container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters"). Requires workspace.'),
|
|
727
736
|
url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote".'),
|
|
@@ -870,6 +879,78 @@ export const TOOL_REGISTRY = [
|
|
|
870
879
|
return { content: [{ type: 'text', text: `Restored "${result.title}" [${docId}] from archive` }] };
|
|
871
880
|
},
|
|
872
881
|
},
|
|
882
|
+
{
|
|
883
|
+
name: 'compile_manuscript',
|
|
884
|
+
description: 'Compile a manuscript doc (content_type "manuscript") into the assembled master book and report its structure + any problems — WITHOUT writing a file. Resolves every `doc:` pointer in the manifest, concatenates the canonical (accepted) bodies in manifest order under their chapter headings, namespaces footnotes, then returns: title, per-chapter word counts, chapter count, total word count, and warnings. Warnings flag unresolved pointers — a beat that points at a missing/renamed/archived doc — so this is the build-time feedback loop: run it to confirm the binding resolves and see how each chapter is sizing up. Pass includeMarkdown:true to also return the full assembled markdown (large for a real book). Target the manifest by docId (8-char hex).',
|
|
885
|
+
schema: {
|
|
886
|
+
docId: z.string().describe('The manuscript doc (the manifest) by docId (8-char hex from list_documents).'),
|
|
887
|
+
includeMarkdown: z.boolean().optional().describe('Also return the full assembled master markdown. Off by default — for a long book this is very large.'),
|
|
888
|
+
},
|
|
889
|
+
handler: async ({ docId, includeMarkdown }) => {
|
|
890
|
+
const ms = loadManifest(docId);
|
|
891
|
+
if (!ms)
|
|
892
|
+
return { content: [{ type: 'text', text: `No manuscript doc found for docId ${docId}. Is it content_type "manuscript"?` }] };
|
|
893
|
+
const { markdown, meta, warnings } = compileManuscript(ms.body, ms.meta);
|
|
894
|
+
const wc = (s) => { const t = s.trim(); return t ? t.split(/\s+/).length : 0; };
|
|
895
|
+
const chapters = [];
|
|
896
|
+
let cur = null;
|
|
897
|
+
for (const line of markdown.split('\n')) {
|
|
898
|
+
const h = line.match(/^# (.+)/);
|
|
899
|
+
if (h) {
|
|
900
|
+
cur = { title: h[1].trim(), words: 0 };
|
|
901
|
+
chapters.push(cur);
|
|
902
|
+
}
|
|
903
|
+
else if (cur)
|
|
904
|
+
cur.words += wc(line);
|
|
905
|
+
}
|
|
906
|
+
const summary = {
|
|
907
|
+
title: meta.title,
|
|
908
|
+
chapterCount: chapters.length,
|
|
909
|
+
totalWords: wc(markdown),
|
|
910
|
+
chapters,
|
|
911
|
+
warnings,
|
|
912
|
+
};
|
|
913
|
+
if (includeMarkdown)
|
|
914
|
+
summary.markdown = markdown;
|
|
915
|
+
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
name: 'export_manuscript',
|
|
920
|
+
description: 'Compile a manuscript doc and WRITE the rendered book to a file: epub (KDP-ready ebook), docx (Word), html (single styled file), or md (the raw assembled master markdown). Same compile + render path as the in-app preview, so the file matches what you see there. Writes to ~/.openwriter/exports/<title>.<ext> by default (or an explicit outputPath) and returns the absolute path plus any compile warnings. EPUB/HTML ship print-light (e-readers handle their own dark mode). Target the manifest by docId (8-char hex).',
|
|
921
|
+
schema: {
|
|
922
|
+
docId: z.string().describe('The manuscript doc (the manifest) by docId (8-char hex from list_documents).'),
|
|
923
|
+
format: z.enum(['epub', 'docx', 'html', 'md']).describe('Output format. epub = KDP-ready ebook; docx = Word; html = single styled file; md = raw assembled master markdown.'),
|
|
924
|
+
outputPath: z.string().optional().describe('Absolute file path to write. Defaults to ~/.openwriter/exports/<title>.<ext>.'),
|
|
925
|
+
},
|
|
926
|
+
handler: async ({ docId, format, outputPath }) => {
|
|
927
|
+
const ms = loadManifest(docId);
|
|
928
|
+
if (!ms)
|
|
929
|
+
return { content: [{ type: 'text', text: `No manuscript doc found for docId ${docId}. Is it content_type "manuscript"?` }] };
|
|
930
|
+
const result = compileManuscript(ms.body, ms.meta);
|
|
931
|
+
const dir = join(ROOT_DIR, 'exports');
|
|
932
|
+
mkdirSync(dir, { recursive: true });
|
|
933
|
+
const outPath = outputPath || join(dir, `${safeName(result.meta.title || '')}.${format}`);
|
|
934
|
+
switch (format) {
|
|
935
|
+
case 'epub':
|
|
936
|
+
writeFileSync(outPath, await renderEpub(result.markdown, result.meta));
|
|
937
|
+
break;
|
|
938
|
+
case 'docx':
|
|
939
|
+
writeFileSync(outPath, await renderDocx(result.markdown, result.meta));
|
|
940
|
+
break;
|
|
941
|
+
case 'html':
|
|
942
|
+
writeFileSync(outPath, renderBookHtml(result.markdown, result.meta), 'utf-8');
|
|
943
|
+
break;
|
|
944
|
+
case 'md':
|
|
945
|
+
writeFileSync(outPath, result.markdown, 'utf-8');
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
const warn = result.warnings.length
|
|
949
|
+
? ` — ${result.warnings.length} warning(s): ${result.warnings.slice(0, 3).join('; ')}${result.warnings.length > 3 ? ' …' : ''}`
|
|
950
|
+
: '';
|
|
951
|
+
return { content: [{ type: 'text', text: `Exported "${result.meta.title}" (${format}) → ${outPath}${warn}` }] };
|
|
952
|
+
},
|
|
953
|
+
},
|
|
873
954
|
{
|
|
874
955
|
name: 'get_metadata',
|
|
875
956
|
description: 'Get the JSON frontmatter metadata for a document. Returns all key-value pairs stored in frontmatter (title, summary, characters, tags, etc.). Useful for understanding document context without reading full content.',
|
|
@@ -913,8 +994,23 @@ export const TOOL_REGISTRY = [
|
|
|
913
994
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
914
995
|
metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
|
|
915
996
|
},
|
|
916
|
-
handler: async ({ docId, metadata:
|
|
997
|
+
handler: async ({ docId, metadata: rawUpdates }) => {
|
|
917
998
|
const target = resolveDocTarget(docId);
|
|
999
|
+
// MCP-9: strip control keys. `autoAccept` governs the human accept/reject
|
|
1000
|
+
// gate — an agent that could set it via set_metadata would self-grant
|
|
1001
|
+
// auto-accept and bypass human review entirely. The approval-mode flag is
|
|
1002
|
+
// operator-only (UI toggle → setDocAutoAccept / setWorkspaceAutoAccept).
|
|
1003
|
+
// Stripping covers both set AND remove: deleting an explicit
|
|
1004
|
+
// `autoAccept: false` would re-enable workspace-inherited auto-accept.
|
|
1005
|
+
const updates = {};
|
|
1006
|
+
const blockedKeys = [];
|
|
1007
|
+
for (const [key, value] of Object.entries(rawUpdates)) {
|
|
1008
|
+
if (AGENT_FORBIDDEN_METADATA_KEYS.has(key)) {
|
|
1009
|
+
blockedKeys.push(key);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
updates[key] = value;
|
|
1013
|
+
}
|
|
918
1014
|
const setKeys = [];
|
|
919
1015
|
const removed = [];
|
|
920
1016
|
for (const [key, value] of Object.entries(updates)) {
|
|
@@ -988,6 +1084,8 @@ export const TOOL_REGISTRY = [
|
|
|
988
1084
|
parts.push(`set: ${keys.join(', ')}`);
|
|
989
1085
|
if (removed.length > 0)
|
|
990
1086
|
parts.push(`removed: ${removed.join(', ')}`);
|
|
1087
|
+
if (blockedKeys.length > 0)
|
|
1088
|
+
parts.push(`ignored (operator-only): ${blockedKeys.join(', ')}`);
|
|
991
1089
|
return { content: [{ type: 'text', text: `Metadata updated (${parts.join('; ')})` }] };
|
|
992
1090
|
},
|
|
993
1091
|
},
|
|
@@ -1334,7 +1432,7 @@ export const TOOL_REGISTRY = [
|
|
|
1334
1432
|
settings: z.record(z.string()).optional().describe('Setting name → description (merged)'),
|
|
1335
1433
|
rules: z.array(z.string()).optional().describe('Writing rules for this workspace (replaces)'),
|
|
1336
1434
|
logline: z.string().nullable().optional().describe('One-sentence "what this workspace is for". Set null to clear.'),
|
|
1337
|
-
domain: z.string().nullable().optional().describe('Subject area (e.g. "
|
|
1435
|
+
domain: z.string().nullable().optional().describe('Subject area (e.g. "Marine biology"). Set null to clear.'),
|
|
1338
1436
|
schema: z.string().nullable().optional().describe('Workspace kind: book / concept-library / inbox / social / reference. Set null to clear.'),
|
|
1339
1437
|
vocab: z.array(z.string()).nullable().optional().describe('Closed list of valid domain names — Haiku classifies docs INTO these. Set null to clear (opens vocab to free-form).'),
|
|
1340
1438
|
relatedWorkspaces: z.array(z.string()).nullable().optional().describe('Sibling workspace filenames. Set null to clear.'),
|
|
@@ -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,
|
package/dist/server/state.js
CHANGED
|
@@ -831,7 +831,7 @@ export function mergeMetadataUpdates(existing, updates) {
|
|
|
831
831
|
return null;
|
|
832
832
|
}
|
|
833
833
|
// Deep-merge known context objects
|
|
834
|
-
const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext'];
|
|
834
|
+
const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext', 'manuscriptContext'];
|
|
835
835
|
for (const key of CONTEXT_KEYS) {
|
|
836
836
|
if (updates[key] && typeof updates[key] === 'object' && existing?.[key] && typeof existing[key] === 'object') {
|
|
837
837
|
updates[key] = { ...existing[key], ...updates[key] };
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Manifests live in ~/.openwriter/_workspaces/*.json.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
7
|
-
import { join } from 'path';
|
|
7
|
+
import { join, resolve, isAbsolute, sep } from 'path';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import matter from 'gray-matter';
|
|
10
10
|
import trash from 'trash';
|
|
@@ -17,8 +17,44 @@ import { addDocToContainer, addContainer as addContainerToTree, removeNode, move
|
|
|
17
17
|
// ============================================================================
|
|
18
18
|
// INTERNAL HELPERS
|
|
19
19
|
// ============================================================================
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a workspace manifest filename to an absolute path that is GUARANTEED
|
|
22
|
+
* to live inside the active profile's `_workspaces/` directory.
|
|
23
|
+
*
|
|
24
|
+
* The `filename` originates from MCP tool args (`wsFile`, `filename`), so it is
|
|
25
|
+
* untrusted. Without this guard a value like `../../OtherProfile/_workspaces/x.json`
|
|
26
|
+
* or an absolute path would escape the active profile and read/write/delete
|
|
27
|
+
* another profile's manifests (and, via the doc files they reference, another
|
|
28
|
+
* profile's documents). Profile scoping in OpenWriter is enforced by anchoring
|
|
29
|
+
* every manifest path under `getWorkspacesDir()` (which encodes the active
|
|
30
|
+
* profile) — so containment IS profile scoping. Escaping containment is the
|
|
31
|
+
* only way to cross profiles, and this resolver makes that impossible.
|
|
32
|
+
*
|
|
33
|
+
* Rules: no separators, no `..`, not absolute, no null byte, must end in
|
|
34
|
+
* `.json`, never the reserved `_order.json`. Then a `path.resolve` +
|
|
35
|
+
* prefix-containment assert is the authoritative backstop.
|
|
36
|
+
*
|
|
37
|
+
* adr: (MCP-7 — workspace path traversal + profile scoping)
|
|
38
|
+
*/
|
|
20
39
|
function workspacePath(filename) {
|
|
21
|
-
|
|
40
|
+
if (!filename || typeof filename !== 'string') {
|
|
41
|
+
throw new Error('Invalid workspace identifier');
|
|
42
|
+
}
|
|
43
|
+
if (filename.includes('\0') ||
|
|
44
|
+
filename.includes('/') ||
|
|
45
|
+
filename.includes('\\') ||
|
|
46
|
+
filename.includes('..') ||
|
|
47
|
+
isAbsolute(filename) ||
|
|
48
|
+
!filename.endsWith('.json') ||
|
|
49
|
+
filename === '_order.json') {
|
|
50
|
+
throw new Error('Invalid workspace identifier');
|
|
51
|
+
}
|
|
52
|
+
const baseDir = resolve(getWorkspacesDir());
|
|
53
|
+
const resolved = resolve(baseDir, filename);
|
|
54
|
+
if (resolved !== baseDir && !resolved.startsWith(baseDir + sep)) {
|
|
55
|
+
throw new Error('Invalid workspace identifier');
|
|
56
|
+
}
|
|
57
|
+
return resolved;
|
|
22
58
|
}
|
|
23
59
|
/**
|
|
24
60
|
* Migrate workspace-level tags into document frontmatter.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"@xdevplatform/xdk": "^0.4.0",
|
|
64
64
|
"express": "^4.21.0",
|
|
65
65
|
"gray-matter": "^4.0.3",
|
|
66
|
+
"jszip": "^3.10.1",
|
|
66
67
|
"lowlight": "^3.3.0",
|
|
67
68
|
"markdown-it": "^14.1.1",
|
|
68
69
|
"markdown-it-footnote": "^4.0.0",
|