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.
@@ -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: updates }) => {
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. "Male ethology"). Set null to clear.'),
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
- managed.config = { ...managed.config, ...values };
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,
@@ -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
- return join(getWorkspacesDir(), filename);
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.37.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",