ofw-mcp 2.19.2 → 2.19.4
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +47 -9
- package/dist/auth-password.js +39 -1
- package/dist/bundle.js +1114 -245
- package/dist/config.js +16 -0
- package/dist/extract/spreadsheet.js +33 -10
- package/dist/index.js +1 -1
- package/dist/sync.js +3 -2
- package/dist/timestamps.js +42 -0
- package/dist/tools/_confirm.js +49 -0
- package/dist/tools/_shared.js +79 -2
- package/dist/tools/attachments.js +85 -8
- package/dist/tools/calendar.js +143 -15
- package/dist/tools/draft-freshness.js +1 -1
- package/dist/tools/expenses.js +39 -5
- package/dist/tools/journal.js +14 -2
- package/dist/tools/messages.js +201 -34
- package/mint.yaml +27 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +3 -1
package/dist/config.js
CHANGED
|
@@ -33,8 +33,24 @@ export function getAttachmentsDir() {
|
|
|
33
33
|
// Claude Desktop, so files written there are unreadable to the model that
|
|
34
34
|
// just downloaded them. Downloads is the standard "user-accessible files"
|
|
35
35
|
// location across macOS/Linux/Windows.
|
|
36
|
+
return getDefaultAttachmentsDir();
|
|
37
|
+
}
|
|
38
|
+
/** The dedicated default attachments directory, `~/Downloads/ofw-mcp`. */
|
|
39
|
+
export function getDefaultAttachmentsDir() {
|
|
36
40
|
return join(homedir(), 'Downloads', 'ofw-mcp');
|
|
37
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The only directory tree ofw_upload_attachment may read from. Uploading sends
|
|
44
|
+
* a local file to OurFamilyWizard (and, when shared, straight to the
|
|
45
|
+
* co-parent), so the source is confined to a directory the user deliberately
|
|
46
|
+
* put files in: OFW_UPLOAD_DIR, else the attachments directory.
|
|
47
|
+
*/
|
|
48
|
+
export function getUploadDir() {
|
|
49
|
+
const override = process.env.OFW_UPLOAD_DIR;
|
|
50
|
+
if (override && override.trim().length > 0)
|
|
51
|
+
return override.trim();
|
|
52
|
+
return getAttachmentsDir();
|
|
53
|
+
}
|
|
38
54
|
/**
|
|
39
55
|
* Gate for write-tool registration, read at registration time (startup).
|
|
40
56
|
*
|
|
@@ -12,6 +12,9 @@ import { readZip } from './zip.js';
|
|
|
12
12
|
import { elements, attr, textOf } from './xml.js';
|
|
13
13
|
import { readRels, findPart, dirOf, resolvePartPath } from './ooxml.js';
|
|
14
14
|
const DEFAULT_MAX_CELLS = 200_000;
|
|
15
|
+
const DEFAULT_MAX_GRID_CELLS = 2_000_000;
|
|
16
|
+
/** Excel's hard sheet width: column XFD. A reference past it is malformed. */
|
|
17
|
+
const MAX_COLUMNS = 16_384;
|
|
15
18
|
// Built-in number formats that denote a date and/or time (ECMA-376 §18.8.30).
|
|
16
19
|
const BUILTIN_DATE_FORMATS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);
|
|
17
20
|
/** Excel's serial-date epoch as a Unix-epoch offset, in days. */
|
|
@@ -47,15 +50,20 @@ function isDateFormat(numFmtId, formatCode) {
|
|
|
47
50
|
const bare = formatCode.replace(/"[^"]*"/g, '').replace(/\[[^\]]*\]/g, '').replace(/\\./g, '');
|
|
48
51
|
return /[ymdhs]/i.test(bare);
|
|
49
52
|
}
|
|
50
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Column index (0-based) from a cell reference like `AA12`, or null when the
|
|
55
|
+
* reference has no column letters or points past Excel's last column (XFD).
|
|
56
|
+
* The bound matters: a crafted `DZZZZZZ1` would otherwise ask for ~1.4e9
|
|
57
|
+
* columns and the CSV build would die in an uncatchable heap OOM.
|
|
58
|
+
*/
|
|
51
59
|
function columnIndex(ref) {
|
|
52
|
-
const m = /^([A-Z]
|
|
60
|
+
const m = /^([A-Z]{1,3})(?![A-Z])/.exec(ref);
|
|
53
61
|
if (!m)
|
|
54
62
|
return null;
|
|
55
63
|
let index = 0;
|
|
56
64
|
for (const ch of m[1])
|
|
57
65
|
index = index * 26 + (ch.charCodeAt(0) - 64);
|
|
58
|
-
return index - 1;
|
|
66
|
+
return index > MAX_COLUMNS ? null : index - 1;
|
|
59
67
|
}
|
|
60
68
|
/** Quote a CSV field per RFC 4180 when it contains a delimiter, quote or newline. */
|
|
61
69
|
function csvField(value) {
|
|
@@ -121,7 +129,7 @@ function cellValue(attrs, inner, ctx) {
|
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
131
|
}
|
|
124
|
-
function parseSheet(xml, name, ctx, maxCells) {
|
|
132
|
+
function parseSheet(xml, name, ctx, maxCells, maxGridCells) {
|
|
125
133
|
const rows = [];
|
|
126
134
|
let cols = 0;
|
|
127
135
|
let cells = 0;
|
|
@@ -132,6 +140,7 @@ function parseSheet(xml, name, ctx, maxCells) {
|
|
|
132
140
|
break;
|
|
133
141
|
}
|
|
134
142
|
const values = [];
|
|
143
|
+
let rowCols = cols;
|
|
135
144
|
for (const cell of elements(row.inner, 'c')) {
|
|
136
145
|
const ref = attr(cell.attrs, 'r');
|
|
137
146
|
const index = ref === null ? null : columnIndex(ref);
|
|
@@ -141,9 +150,15 @@ function parseSheet(xml, name, ctx, maxCells) {
|
|
|
141
150
|
continue;
|
|
142
151
|
values[index] = cellValue(cell.attrs, cell.inner, ctx);
|
|
143
152
|
cells++;
|
|
144
|
-
if (index + 1 >
|
|
145
|
-
|
|
153
|
+
if (index + 1 > rowCols)
|
|
154
|
+
rowCols = index + 1;
|
|
155
|
+
}
|
|
156
|
+
// Stop before the padded grid the CSV is built from outgrows its budget.
|
|
157
|
+
if ((rows.length + 1) * rowCols > maxGridCells) {
|
|
158
|
+
truncated = true;
|
|
159
|
+
break;
|
|
146
160
|
}
|
|
161
|
+
cols = rowCols;
|
|
147
162
|
rows.push(values);
|
|
148
163
|
}
|
|
149
164
|
return { name, rows: rows.length, cols, csv: toCsv(rows, cols), ...(truncated ? { truncated } : {}) };
|
|
@@ -165,6 +180,7 @@ export async function extractXlsx(bytes, opts = {}) {
|
|
|
165
180
|
date1904: /<workbookPr[^>]*date1904="(1|true)"/i.test(workbookXml),
|
|
166
181
|
};
|
|
167
182
|
const maxCells = opts.maxCells ?? DEFAULT_MAX_CELLS;
|
|
183
|
+
const maxGridCells = opts.maxGridCells ?? DEFAULT_MAX_GRID_CELLS;
|
|
168
184
|
const sheets = [];
|
|
169
185
|
const omitted = [];
|
|
170
186
|
let index = 0;
|
|
@@ -185,7 +201,7 @@ export async function extractXlsx(bytes, opts = {}) {
|
|
|
185
201
|
omitted.push(`${name} (sheet part not found in the workbook)`);
|
|
186
202
|
continue;
|
|
187
203
|
}
|
|
188
|
-
sheets.push(parseSheet(xml, name, ctx, maxCells));
|
|
204
|
+
sheets.push(parseSheet(xml, name, ctx, maxCells, maxGridCells));
|
|
189
205
|
}
|
|
190
206
|
const truncated = sheets.some((s) => s.truncated);
|
|
191
207
|
return {
|
|
@@ -248,11 +264,18 @@ function parseDelimitedRows(text, delimiter) {
|
|
|
248
264
|
return rows;
|
|
249
265
|
}
|
|
250
266
|
/** Extract .csv/.tsv text as a single-sheet spreadsheet. */
|
|
251
|
-
export function extractDelimited(text, name, delimiter) {
|
|
252
|
-
|
|
267
|
+
export function extractDelimited(text, name, delimiter, maxGridCells = DEFAULT_MAX_GRID_CELLS) {
|
|
268
|
+
let rows = parseDelimitedRows(text, delimiter);
|
|
253
269
|
const cols = rows.reduce((max, r) => Math.max(max, r.length), 0);
|
|
270
|
+
// Every row is padded to the widest one, so a single very wide line would
|
|
271
|
+
// otherwise multiply the output by the row count.
|
|
272
|
+
const keep = cols === 0 ? rows.length : Math.floor(maxGridCells / cols);
|
|
273
|
+
const truncated = rows.length > keep;
|
|
274
|
+
if (truncated)
|
|
275
|
+
rows = rows.slice(0, keep);
|
|
254
276
|
return {
|
|
255
277
|
kind: 'spreadsheet',
|
|
256
|
-
sheets: [{ name, rows: rows.length, cols, csv: toCsv(rows, cols) }],
|
|
278
|
+
sheets: [{ name, rows: rows.length, cols, csv: toCsv(rows, cols), ...(truncated ? { truncated } : {}) }],
|
|
279
|
+
...(truncated ? { truncated } : {}),
|
|
257
280
|
};
|
|
258
281
|
}
|
package/dist/index.js
CHANGED
|
@@ -36,7 +36,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
|
|
|
36
36
|
// always succeeds before any credential check runs.
|
|
37
37
|
await runMcp({
|
|
38
38
|
name: 'ofw',
|
|
39
|
-
version: '2.19.
|
|
39
|
+
version: '2.19.4', // x-release-please-version
|
|
40
40
|
deps: client,
|
|
41
41
|
tools: [
|
|
42
42
|
registerHealthcheckTools,
|
package/dist/sync.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ApiRecipientSchema, hasRealView, mapRecipients, threadedReplyTo } from './tools/_shared.js';
|
|
3
3
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
4
|
+
import { nowNaiveWallClock } from './timestamps.js';
|
|
4
5
|
// Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
|
|
5
6
|
// for each file id (cheap JSON call) so the model can see filenames/mime types
|
|
6
7
|
// without downloading bytes. Bytes are pulled lazily by ofw_download_attachment.
|
|
@@ -212,7 +213,7 @@ async function walkPages(client, folder, folderId, opts, store) {
|
|
|
212
213
|
folder,
|
|
213
214
|
subject: item.subject ?? '(no subject)',
|
|
214
215
|
fromUser: item.from?.name ?? '',
|
|
215
|
-
sentAt: item.date?.dateTime ??
|
|
216
|
+
sentAt: item.date?.dateTime ?? nowNaiveWallClock(),
|
|
216
217
|
recipients: mapRecipients(detailRecipients ?? item.recipients),
|
|
217
218
|
body,
|
|
218
219
|
fetchedBodyAt,
|
|
@@ -457,7 +458,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
457
458
|
body: detail.body ?? '',
|
|
458
459
|
recipients: mapRecipients(item.recipients),
|
|
459
460
|
replyToId: threadedReplyTo(item),
|
|
460
|
-
modifiedAt: item.date?.dateTime ??
|
|
461
|
+
modifiedAt: item.date?.dateTime ?? nowNaiveWallClock(),
|
|
461
462
|
listData: item,
|
|
462
463
|
});
|
|
463
464
|
}
|
package/dist/timestamps.js
CHANGED
|
@@ -236,6 +236,48 @@ export function parseTimestampValue(key, value, assumeNaiveIn) {
|
|
|
236
236
|
}
|
|
237
237
|
return null;
|
|
238
238
|
}
|
|
239
|
+
// Render an instant as the NAIVE wall clock OFW itself stores (no offset), in
|
|
240
|
+
// `tz`. The inverse of how a naive source value is read above.
|
|
241
|
+
function naiveWallClockOf(instant, tz) {
|
|
242
|
+
return isoWithOffset(instant, tz, '');
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The current time as naive wall clock in the display zone — the fallback for a
|
|
246
|
+
* record OFW returned without a date. A UTC `...Z` there would mix two formats
|
|
247
|
+
* in one column and break every lexicographic since/until comparison on it.
|
|
248
|
+
*/
|
|
249
|
+
export function nowNaiveWallClock(tz = displayTimeZone()) {
|
|
250
|
+
return naiveWallClockOf(new Date(), tz);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Convert a caller-supplied date bound to the form `sent_at` is stored in:
|
|
254
|
+
* OFW's naive local wall clock (`YYYY-MM-DDTHH:MM:SS[.mmm]`) in `tz`. An
|
|
255
|
+
* offset or `Z` value is converted as an instant; a naive value is already wall
|
|
256
|
+
* clock and is only canonicalised; a bare `YYYY-MM-DD` passes through, since it
|
|
257
|
+
* sorts correctly against the stored values as a prefix. Returns null for a
|
|
258
|
+
* value that is not a date, so it can be rejected instead of string-compared.
|
|
259
|
+
*/
|
|
260
|
+
export function toNaiveWallClock(value, tz = displayTimeZone()) {
|
|
261
|
+
const raw = value.trim();
|
|
262
|
+
if (DATE_ONLY.test(raw))
|
|
263
|
+
return raw;
|
|
264
|
+
if (RFC3339_WITH_OFFSET.test(raw)) {
|
|
265
|
+
const instant = parseTimestampValue('', raw, tz);
|
|
266
|
+
return instant ? naiveWallClockOf(instant, tz) : null;
|
|
267
|
+
}
|
|
268
|
+
const naive = NAIVE_DATE_TIME.exec(raw);
|
|
269
|
+
if (!naive)
|
|
270
|
+
return null;
|
|
271
|
+
const [, y, mo, d, h, mi, s, frac] = naive;
|
|
272
|
+
const parts = {
|
|
273
|
+
year: Number(y), month: Number(mo), day: Number(d),
|
|
274
|
+
hour: Number(h), minute: Number(mi), second: Number(s ?? '0'),
|
|
275
|
+
};
|
|
276
|
+
if (!isRealCalendarDate(parts))
|
|
277
|
+
return null;
|
|
278
|
+
const ms = frac ? `.${frac.padEnd(3, '0').slice(0, 3)}` : '';
|
|
279
|
+
return `${y}-${mo}-${d}T${h}:${mi}:${pad(parts.second)}${ms}`;
|
|
280
|
+
}
|
|
239
281
|
// True when a string carries no zone information — the shape this whole module
|
|
240
282
|
// exists to eliminate. Used by the contract test.
|
|
241
283
|
export function isNaiveTimestamp(value) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
|
|
2
|
+
import { fnv1a64 } from './draft-freshness.js';
|
|
3
|
+
export { confirmTokenParam };
|
|
4
|
+
/** The sentence every confirm-gated tool's description ends with. */
|
|
5
|
+
export const CONFIRM_NOTE = 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call '
|
|
6
|
+
+ 'performs NO write and returns a preview of exactly what would happen plus a confirmToken, and only a repeat '
|
|
7
|
+
+ 'call with that token proceeds (see MCP_CONFIRM_MODE).';
|
|
8
|
+
/**
|
|
9
|
+
* Confirm-gate for a write that reaches the co-parent or the court-visible
|
|
10
|
+
* record. A client that can show a confirmation prompt is asked; one that
|
|
11
|
+
* cannot (claude.ai, Claude Desktop — measured in mcp-utils
|
|
12
|
+
* docs/CLIENT-BEHAVIOUR.md) gets the two-phase token flow governed by
|
|
13
|
+
* `MCP_CONFIRM_MODE`: phase 1 performs no write and returns the preview plus a
|
|
14
|
+
* `confirmToken`; only a repeat call with that token proceeds. The token is
|
|
15
|
+
* bound to this tool, the target, its revision and the exact payload, is
|
|
16
|
+
* single-use and expires (`MCP_CONFIRM_TTL_SECONDS`).
|
|
17
|
+
*
|
|
18
|
+
* Call it on EVERY invocation with the freshly-built payload and a freshly
|
|
19
|
+
* read revision — the caller's own re-read is what makes a stale token fail.
|
|
20
|
+
* `OFW_WRITE_MODE` stays the structural layer underneath: a tool this gate
|
|
21
|
+
* protects is still not registered at all below its write mode.
|
|
22
|
+
*
|
|
23
|
+
* Returns `undefined` to proceed with the write, otherwise the result to
|
|
24
|
+
* return unchanged.
|
|
25
|
+
*/
|
|
26
|
+
export function confirmWrite(ctx, opts) {
|
|
27
|
+
return requireConfirmationWithFallback(ctx, confirmationFromEnv({
|
|
28
|
+
action: opts.action,
|
|
29
|
+
message: opts.message,
|
|
30
|
+
details: opts.preview,
|
|
31
|
+
unsupportedNote: 'Complete this action on ourfamilywizard.com instead.',
|
|
32
|
+
tool: opts.tool,
|
|
33
|
+
confirmToken: opts.confirmToken,
|
|
34
|
+
subject: () => ({
|
|
35
|
+
target: opts.target,
|
|
36
|
+
...(opts.revision !== undefined ? { revision: opts.revision } : {}),
|
|
37
|
+
payload: opts.payload,
|
|
38
|
+
preview: opts.preview,
|
|
39
|
+
}),
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A revision string for a target as just read from OFW (an event detail), for
|
|
44
|
+
* `ConfirmWriteOptions.revision`. Any change to the value rotates it, so a
|
|
45
|
+
* token minted against one state cannot act on another.
|
|
46
|
+
*/
|
|
47
|
+
export function stateRevision(value) {
|
|
48
|
+
return `s1:${fnv1a64(JSON.stringify(value))}`;
|
|
49
|
+
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -238,13 +238,90 @@ const PostMessagesResponseSchema = z.looseObject({
|
|
|
238
238
|
* `<S extends z.ZodType>` constraint would widen the output to `unknown`
|
|
239
239
|
* and force a cast at this call site.)
|
|
240
240
|
*/
|
|
241
|
+
/**
|
|
242
|
+
* A message write whose outcome is UNKNOWN: the POST failed without a
|
|
243
|
+
* definitive answer (timeout, dropped connection, 5xx), or OFW accepted it
|
|
244
|
+
* (`postedId`) and the re-fetch that confirms it failed. Either way the write
|
|
245
|
+
* may have landed, so a caller must not treat it as "nothing happened" — for
|
|
246
|
+
* a send, retrying would put a duplicate on the court-visible record. The
|
|
247
|
+
* message is the underlying error's, so callers that do not care see the same
|
|
248
|
+
* text as before.
|
|
249
|
+
*/
|
|
250
|
+
export class UnconfirmedWriteError extends Error {
|
|
251
|
+
postedId;
|
|
252
|
+
constructor(postedId, cause) {
|
|
253
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
254
|
+
this.postedId = postedId;
|
|
255
|
+
this.name = 'UnconfirmedWriteError';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* True when a failed request is a DEFINITIVE rejection — OFW answered with a
|
|
260
|
+
* 4xx (other than 408 Request Timeout) or a repeated 429 — so the write
|
|
261
|
+
* certainly did not happen. Anything else (timeout, network error, 5xx,
|
|
262
|
+
* cancellation) leaves the outcome unknown.
|
|
263
|
+
*/
|
|
264
|
+
function isDefinitiveRejection(e) {
|
|
265
|
+
if (!(e instanceof Error))
|
|
266
|
+
return false;
|
|
267
|
+
if (/^Rate limited by OFW API/.test(e.message))
|
|
268
|
+
return true;
|
|
269
|
+
const m = /OFW API error: (4\d\d)\b/.exec(e.message);
|
|
270
|
+
return m !== null && m[1] !== '408';
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Issue a write that lands on the shared, court-visible record (an expense,
|
|
274
|
+
* a calendar change, a journal entry) and classify how it failed. A
|
|
275
|
+
* definitive rejection (4xx, repeated 429) is rethrown as-is: nothing landed
|
|
276
|
+
* and a retry is safe. Anything else — a timeout, a dropped connection, a
|
|
277
|
+
* 5xx, a cancellation — is an UnconfirmedWriteError, because OFW may already
|
|
278
|
+
* have accepted the write, and a model that reads a plain "request timed out"
|
|
279
|
+
* retries it and puts a duplicate in front of the co-parent.
|
|
280
|
+
*/
|
|
281
|
+
export async function requestWrite(client, method, path, body) {
|
|
282
|
+
try {
|
|
283
|
+
return await client.request(method, path, body);
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
throw isDefinitiveRejection(e) ? e : new UnconfirmedWriteError(null, e);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The `<X>_UNCONFIRMED` result for a write whose outcome is unknown: an
|
|
291
|
+
* error result (so it is not read as success) that says in words the write
|
|
292
|
+
* may have landed and names how to check before any retry.
|
|
293
|
+
*/
|
|
294
|
+
export function unconfirmedWriteResponse(e, opts) {
|
|
295
|
+
return jsonErrorResponse({
|
|
296
|
+
result: opts.result,
|
|
297
|
+
mayHaveLanded: true,
|
|
298
|
+
reason: `The request to ${opts.what} failed without a definitive answer from OFW: ${e.message}. It MAY HAVE BEEN APPLIED on OurFamilyWizard.`,
|
|
299
|
+
remedy: `Do NOT retry blindly — a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith} (or on ourfamilywizard.com), and retry only once you have confirmed it did not land.`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
241
302
|
export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
|
|
242
|
-
|
|
303
|
+
let posted;
|
|
304
|
+
try {
|
|
305
|
+
posted = await client.request('POST', '/pub/v3/messages', payload);
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
throw isDefinitiveRejection(e) ? e : new UnconfirmedWriteError(null, e);
|
|
309
|
+
}
|
|
310
|
+
const raw = parseLenient(PostMessagesResponseSchema, posted, { label: 'ofw-mcp', context: `POST /pub/v3/messages (${ctx})`, mode: 'strict' });
|
|
243
311
|
const id = typeof raw?.id === 'number' ? raw.id
|
|
244
312
|
: typeof raw?.entityId === 'number' ? raw.entityId
|
|
245
313
|
: null;
|
|
246
314
|
if (id === null)
|
|
247
315
|
return { id: null, detail: null, raw };
|
|
248
|
-
|
|
316
|
+
let fetched;
|
|
317
|
+
try {
|
|
318
|
+
fetched = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
// OFW already accepted the write (it returned an id); only the
|
|
322
|
+
// confirmation failed.
|
|
323
|
+
throw new UnconfirmedWriteError(id, e);
|
|
324
|
+
}
|
|
325
|
+
const detail = parseLenient(detailSchema, fetched, { label: 'ofw-mcp', context: `GET /pub/v3/messages/{id} (${ctx})`, mode: 'strict' });
|
|
249
326
|
return { id, detail, raw };
|
|
250
327
|
}
|
|
@@ -8,9 +8,25 @@
|
|
|
8
8
|
// disk injects an inline, filesystem-free implementation.
|
|
9
9
|
// Keeping the interface here means src/tools/messages.ts imports nothing from
|
|
10
10
|
// node:fs.
|
|
11
|
-
import { readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
-
import { basename, dirname, extname } from 'node:path';
|
|
11
|
+
import { chmodSync, existsSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
13
|
+
import { getDefaultAttachmentsDir, getUploadDir } from '../config.js';
|
|
13
14
|
import { fileBlob, expandPath } from '@chrischall/mcp-utils';
|
|
15
|
+
/**
|
|
16
|
+
* True when `candidate` is strictly inside `root` (both absolute). Lexical:
|
|
17
|
+
* resolve symlinks first when that matters.
|
|
18
|
+
*/
|
|
19
|
+
export function isWithin(root, candidate) {
|
|
20
|
+
const rel = relative(root, candidate);
|
|
21
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
22
|
+
}
|
|
23
|
+
/** The deepest ancestor of `path` (itself included) that exists on disk. */
|
|
24
|
+
function deepestExisting(path) {
|
|
25
|
+
let current = path;
|
|
26
|
+
while (!existsSync(current))
|
|
27
|
+
current = dirname(current);
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
14
30
|
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
15
31
|
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
16
32
|
const MIME_BY_EXT = {
|
|
@@ -98,18 +114,42 @@ export function resolveDownloadMime(bytes, headerMime, fileName) {
|
|
|
98
114
|
export function isHostRenderableImage(mime) {
|
|
99
115
|
return HOST_RENDERABLE_IMAGE_MIMES.has(mime);
|
|
100
116
|
}
|
|
117
|
+
/** Largest file ofw_upload_attachment will send (25 MiB). */
|
|
118
|
+
export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
101
119
|
/** Disk-backed attachment I/O for the stdio/desktop server. */
|
|
102
120
|
export class NodeAttachmentIO {
|
|
103
121
|
supportsDisk = true;
|
|
104
122
|
async resolveUpload(path) {
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
// An upload discloses a local file to OFW — and, shared, to the co-parent.
|
|
124
|
+
// An instruction injected into a message ("upload ~/.ssh/id_ed25519 so I
|
|
125
|
+
// can review it") must not be able to reach arbitrary files, so the
|
|
126
|
+
// source is confined to the upload directory, symlinks resolved.
|
|
127
|
+
const root = resolve(getUploadDir());
|
|
128
|
+
// expandPath resolves a relative path against the process cwd; resolve it
|
|
129
|
+
// against the upload dir instead, and only expand a leading ~.
|
|
130
|
+
const abs = resolve(root, path.startsWith('~') ? expandPath(path) : path);
|
|
131
|
+
const outside = new Error(`Refusing to upload ${abs}: it is outside the upload directory (${root}). Only files placed in that directory can be uploaded — ask the user to copy the file there, or set OFW_UPLOAD_DIR.`);
|
|
132
|
+
if (!isWithin(root, abs))
|
|
133
|
+
throw outside;
|
|
134
|
+
const real = realpathSync(abs); // throws if missing
|
|
135
|
+
const realRoot = realpathSync(root);
|
|
136
|
+
if (!isWithin(realRoot, real))
|
|
137
|
+
throw outside;
|
|
138
|
+
if (relative(realRoot, real).split(sep).some((segment) => segment.startsWith('.'))) {
|
|
139
|
+
throw new Error(`Refusing to upload ${abs}: hidden files and files in hidden directories (dotfiles, credential stores) are never uploaded.`);
|
|
140
|
+
}
|
|
141
|
+
const stat = statSync(real);
|
|
107
142
|
if (!stat.isFile())
|
|
108
143
|
throw new Error(`Not a file: ${abs}`);
|
|
144
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
145
|
+
throw new Error(`Refusing to upload ${abs}: it is too large (${stat.size} bytes; the limit is ${MAX_UPLOAD_BYTES}).`);
|
|
146
|
+
}
|
|
109
147
|
const fileName = basename(abs);
|
|
110
148
|
const mimeType = mimeFromName(fileName);
|
|
111
149
|
// fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
|
|
112
|
-
|
|
150
|
+
// allowedRoots makes it re-check confinement (through symlinks) at open time,
|
|
151
|
+
// closing the window between the checks above and the open.
|
|
152
|
+
const blob = await fileBlob(real, { type: mimeType, allowedRoots: [realRoot] });
|
|
113
153
|
return { blob, fileName, mimeType, sizeBytes: stat.size };
|
|
114
154
|
}
|
|
115
155
|
readDownloaded(path) {
|
|
@@ -120,8 +160,45 @@ export class NodeAttachmentIO {
|
|
|
120
160
|
return null;
|
|
121
161
|
}
|
|
122
162
|
}
|
|
123
|
-
writeDownload(dest, bytes) {
|
|
124
|
-
|
|
125
|
-
|
|
163
|
+
writeDownload(dest, bytes, { root, overwrite }) {
|
|
164
|
+
// The bytes are co-parent-supplied, so where they land is the security
|
|
165
|
+
// boundary. Check the REAL path of the nearest existing ancestor before
|
|
166
|
+
// creating anything, so a symlinked directory inside the root cannot carry
|
|
167
|
+
// the write (or even the mkdir) somewhere else.
|
|
168
|
+
//
|
|
169
|
+
// Directories are created 0700: the listing itself is sensitive (entries
|
|
170
|
+
// are `<fileId>-<filename>`, with names the co-parent chose), so 0600
|
|
171
|
+
// bytes in a world-listable directory would still leak them. The
|
|
172
|
+
// DEDICATED default dir is also tightened if it already exists (an older
|
|
173
|
+
// version created it 0755); a directory the user configured themselves
|
|
174
|
+
// keeps its mode, since it may be shared on purpose.
|
|
175
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
176
|
+
if (resolve(root) === resolve(getDefaultAttachmentsDir()))
|
|
177
|
+
chmodSync(root, 0o700);
|
|
178
|
+
const realRoot = realpathSync(root);
|
|
179
|
+
const parent = dirname(dest);
|
|
180
|
+
const anchor = realpathSync(deepestExisting(parent));
|
|
181
|
+
if (anchor !== realRoot && !isWithin(realRoot, anchor)) {
|
|
182
|
+
throw new Error(`Refusing to write ${dest}: it resolves outside the attachments directory (${root}).`);
|
|
183
|
+
}
|
|
184
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
185
|
+
if (overwrite) {
|
|
186
|
+
// unlink removes a symlink itself, never its target.
|
|
187
|
+
try {
|
|
188
|
+
unlinkSync(dest);
|
|
189
|
+
}
|
|
190
|
+
catch { /* nothing there to replace */ }
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
// 'wx' fails on ANY existing entry, a symlink (even dangling) included,
|
|
194
|
+
// so nothing already at `dest` is ever clobbered or followed.
|
|
195
|
+
writeFileSync(dest, bytes, { flag: 'wx', mode: 0o600 });
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
if (e.code === 'EEXIST') {
|
|
199
|
+
throw new Error(`Refusing to overwrite ${dest}: a file already exists there. Pass force:true to replace it, or choose another saveTo.`);
|
|
200
|
+
}
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
126
203
|
}
|
|
127
204
|
}
|