ofw-mcp 2.19.5 → 2.20.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +25 -2
- package/dist/auth-password.js +17 -1
- package/dist/bundle.js +1502 -407
- package/dist/client.js +66 -20
- package/dist/config.js +37 -0
- package/dist/index.js +13 -9
- package/dist/tool-surface.js +14 -0
- package/dist/tools/_shared.js +5 -1
- package/dist/tools/attachments.js +32 -32
- package/dist/tools/expenses.js +563 -56
- package/dist/tools/messages.js +1 -1
- package/package.json +4 -4
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +10 -3
- package/skills/ofw-fpx/references/requests.md +58 -4
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadDotenvSafely, parseBoolEnv, redactSecrets, withAmbientCancellation } from '@chrischall/mcp-utils';
|
|
1
|
+
import { EdgeBlockedError, detectEdgeBlock, loadDotenvSafely, parseContentDispositionFilename, parseBoolEnv, redactSecrets, withAmbientCancellation, } from '@chrischall/mcp-utils';
|
|
2
2
|
import { TokenManager } from '@chrischall/mcp-utils/session';
|
|
3
3
|
import { dirname, join } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
@@ -18,22 +18,6 @@ try {
|
|
|
18
18
|
catch {
|
|
19
19
|
/* v8 ignore next -- only reached in a non-Node runtime: no .env to load */
|
|
20
20
|
}
|
|
21
|
-
// Parse a Content-Disposition header for a filename. Prefers RFC 6266
|
|
22
|
-
// `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
|
|
23
|
-
function parseContentDispositionFilename(cd) {
|
|
24
|
-
const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
|
|
25
|
-
if (extMatch) {
|
|
26
|
-
const raw = extMatch[1].trim().replace(/^"|"$/g, '');
|
|
27
|
-
try {
|
|
28
|
-
return decodeURIComponent(raw);
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return raw;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
const m = /filename="?([^";]+)"?/i.exec(cd);
|
|
35
|
-
return m ? m[1] : null;
|
|
36
|
-
}
|
|
37
21
|
// Set OFW_DEBUG_LOG=1 (or true/yes/on) to log every OFW request/response to
|
|
38
22
|
// stderr. Authorization is redacted. Bodies are logged in full — set this
|
|
39
23
|
// only when debugging, never in normal use.
|
|
@@ -59,6 +43,49 @@ function getRequestTimeoutMs() {
|
|
|
59
43
|
// refresh when its refresh token is `undefined`, so a non-empty placeholder
|
|
60
44
|
// keeps the single-flight refresh path live; the refresh callback ignores it.
|
|
61
45
|
const OFW_REFRESH_SENTINEL = 'ofw';
|
|
46
|
+
/**
|
|
47
|
+
* Throw {@link EdgeBlockedError} when `response` is a CDN/WAF refusal.
|
|
48
|
+
*
|
|
49
|
+
* Its HEADERS are always judged (Cloudflare's `cf-mitigated`). Its BODY is
|
|
50
|
+
* judged only for a 429: that response is about to be waited out and replayed,
|
|
51
|
+
* or turned into "Rate limited", so a challenge page served as 429 without the
|
|
52
|
+
* header would otherwise never reach the body check below the retry and would
|
|
53
|
+
* be reported as a rate limit (chrischall/mcp-host#1015). The body is read
|
|
54
|
+
* from a clone, and never when it is JSON — no refusal page is. Other non-2xx
|
|
55
|
+
* bodies are judged where the error body is read anyway, in `fetchAuthed`, and
|
|
56
|
+
* a 2xx body is never consumed here.
|
|
57
|
+
*/
|
|
58
|
+
async function throwIfEdgeBlocked(response, method, path) {
|
|
59
|
+
if (response.ok)
|
|
60
|
+
return;
|
|
61
|
+
let edge = detectEdgeBlock({ headers: response.headers, status: response.status });
|
|
62
|
+
if (edge === null && response.status === 429 && !/json/i.test(response.headers.get('content-type') ?? '')) {
|
|
63
|
+
let body = '';
|
|
64
|
+
try {
|
|
65
|
+
body = await response.clone().text();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* unreadable: not shown to be a block */
|
|
69
|
+
}
|
|
70
|
+
edge = detectEdgeBlock({ body, headers: response.headers, status: response.status });
|
|
71
|
+
}
|
|
72
|
+
if (edge !== null) {
|
|
73
|
+
await releaseBody(response);
|
|
74
|
+
throw new EdgeBlockedError(response.status, edge.vendor, { service: 'OurFamilyWizard', method, path });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Release a response's connection without reading it. A cancel that fails
|
|
79
|
+
* changes nothing about the answer, so its failure is swallowed.
|
|
80
|
+
*/
|
|
81
|
+
async function releaseBody(response) {
|
|
82
|
+
try {
|
|
83
|
+
await response.body?.cancel();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* already settled */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
62
89
|
export class OFWClient {
|
|
63
90
|
// Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
|
|
64
91
|
// (proactive refresh inside the skew window, single-flight refresh so a burst
|
|
@@ -124,7 +151,7 @@ export class OFWClient {
|
|
|
124
151
|
return {
|
|
125
152
|
body: Buffer.from(await response.arrayBuffer()),
|
|
126
153
|
contentType: response.headers.get('content-type'),
|
|
127
|
-
suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? ''),
|
|
154
|
+
suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? '') ?? null,
|
|
128
155
|
};
|
|
129
156
|
}
|
|
130
157
|
// Authenticated fetch for both JSON and binary callers. Auth (proactive
|
|
@@ -137,14 +164,33 @@ export class OFWClient {
|
|
|
137
164
|
// preserving the prior bespoke-loop diagnostic.
|
|
138
165
|
let attempt = 0;
|
|
139
166
|
let response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, attempt++ > 0));
|
|
167
|
+
// A CDN/WAF refusal page is not OFW answering: the token was never judged.
|
|
168
|
+
// `withAuth` already declines to spend a re-login on one; checked again
|
|
169
|
+
// here BEFORE the 429 replay (a challenge can arrive as 429, and waiting it
|
|
170
|
+
// out only repeats the block) and before the generic error below, so every
|
|
171
|
+
// tool sees an `EdgeBlockedError` it can branch on instead of an
|
|
172
|
+
// "OFW API error" with the page dumped into it (chrischall/mcp-host#1015).
|
|
173
|
+
await throwIfEdgeBlocked(response, method, path);
|
|
140
174
|
if (response.status === 429) {
|
|
175
|
+
// The first 429's body is never read: release it before the replay.
|
|
176
|
+
await releaseBody(response);
|
|
141
177
|
await new Promise((r) => setTimeout(r, 2000));
|
|
142
178
|
response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, true));
|
|
143
|
-
|
|
179
|
+
await throwIfEdgeBlocked(response, method, path);
|
|
180
|
+
if (response.status === 429) {
|
|
181
|
+
await releaseBody(response);
|
|
144
182
|
throw new Error('Rate limited by OFW API');
|
|
183
|
+
}
|
|
145
184
|
}
|
|
146
185
|
if (!response.ok) {
|
|
147
|
-
|
|
186
|
+
const errorBody = await response.text();
|
|
187
|
+
const edge = detectEdgeBlock({ body: errorBody, headers: response.headers, status: response.status });
|
|
188
|
+
if (edge !== null) {
|
|
189
|
+
throw new EdgeBlockedError(response.status, edge.vendor, { service: 'OurFamilyWizard', method, path });
|
|
190
|
+
}
|
|
191
|
+
const safeBody = redactSecrets(errorBody).replace(/\s+/g, ' ').trim().slice(0, 4000);
|
|
192
|
+
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}` +
|
|
193
|
+
(safeBody ? ` — ${safeBody}` : ''));
|
|
148
194
|
}
|
|
149
195
|
return response;
|
|
150
196
|
}
|
package/dist/config.js
CHANGED
|
@@ -51,6 +51,43 @@ export function getUploadDir() {
|
|
|
51
51
|
return override.trim();
|
|
52
52
|
return getAttachmentsDir();
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Structural surface restrictions for deployments dedicated to reimbursement
|
|
56
|
+
* automation.
|
|
57
|
+
*
|
|
58
|
+
* OFW_EXPENSE_ONLY=true removes every non-expense registrar except the
|
|
59
|
+
* healthcheck. Message, calendar, journal and profile/dashboard tools simply do
|
|
60
|
+
* not exist in the server's tools/list response.
|
|
61
|
+
*
|
|
62
|
+
* OFW_EXPENSE_UPLOAD_ONLY=true is stricter and implies OFW_EXPENSE_ONLY: only
|
|
63
|
+
* ofw_upload_expense_pdf + ofw_create_expense + ofw_update_expense (plus
|
|
64
|
+
* ofw_healthcheck) register.
|
|
65
|
+
* Expense reads such as totals/list are omitted as well.
|
|
66
|
+
*
|
|
67
|
+
* These are startup-time structural controls. They cannot be raised by a tool
|
|
68
|
+
* argument, prompt injection, or host permission setting.
|
|
69
|
+
*
|
|
70
|
+
* Unrecognized non-empty values fail CLOSED to true. A typo in a restriction
|
|
71
|
+
* flag must never silently widen the server surface.
|
|
72
|
+
*/
|
|
73
|
+
function restrictiveBoolEnv(name) {
|
|
74
|
+
const raw = process.env[name];
|
|
75
|
+
if (typeof raw !== 'string' || raw.trim().length === 0)
|
|
76
|
+
return false;
|
|
77
|
+
const value = raw.trim().toLowerCase();
|
|
78
|
+
if (['1', 'true', 'yes', 'on'].includes(value))
|
|
79
|
+
return true;
|
|
80
|
+
if (['0', 'false', 'no', 'off'].includes(value))
|
|
81
|
+
return false;
|
|
82
|
+
console.error(`[ofw-mcp] Unrecognized ${name} "${raw.trim()}" — failing closed to "true" (restriction enabled). Valid values: true, false.`);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
export function getExpenseUploadOnly() {
|
|
86
|
+
return restrictiveBoolEnv('OFW_EXPENSE_UPLOAD_ONLY');
|
|
87
|
+
}
|
|
88
|
+
export function getExpenseOnly() {
|
|
89
|
+
return getExpenseUploadOnly() || restrictiveBoolEnv('OFW_EXPENSE_ONLY');
|
|
90
|
+
}
|
|
54
91
|
/**
|
|
55
92
|
* Gate for write-tool registration, read at registration time (startup).
|
|
56
93
|
*
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { registerExpenseTools } from './tools/expenses.js';
|
|
|
19
19
|
import { registerJournalTools } from './tools/journal.js';
|
|
20
20
|
import { OFWCache } from './cache/node.js';
|
|
21
21
|
import { getCacheDbPath } from './config.js';
|
|
22
|
+
import { selectToolRegistrars } from './tool-surface.js';
|
|
22
23
|
import { NodeAttachmentIO } from './tools/attachments.js';
|
|
23
24
|
// The stdio server backs the message cache with a local `node:sqlite` file,
|
|
24
25
|
// opened lazily on first use (so the server still boots and answers the host's
|
|
@@ -34,17 +35,20 @@ const nodeAttachmentIO = new NodeAttachmentIO();
|
|
|
34
35
|
// is preserved: `client` is constructed at module load in ./client.js (auth is
|
|
35
36
|
// resolved lazily on the first tool call), so the host's initial tools/list
|
|
36
37
|
// always succeeds before any credential check runs.
|
|
38
|
+
const expenseRegistrar = (server, deps) => registerExpenseTools(server, deps, nodeAttachmentIO);
|
|
39
|
+
const messageRegistrar = (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO);
|
|
40
|
+
const tools = selectToolRegistrars({
|
|
41
|
+
healthcheck: registerHealthcheckTools,
|
|
42
|
+
user: registerUserTools,
|
|
43
|
+
messages: messageRegistrar,
|
|
44
|
+
calendar: registerCalendarTools,
|
|
45
|
+
expenses: expenseRegistrar,
|
|
46
|
+
journal: registerJournalTools,
|
|
47
|
+
});
|
|
37
48
|
await runMcp({
|
|
38
49
|
name: 'ofw',
|
|
39
|
-
version: '2.
|
|
50
|
+
version: '2.20.1', // x-release-please-version
|
|
40
51
|
deps: client,
|
|
41
|
-
tools
|
|
42
|
-
registerHealthcheckTools,
|
|
43
|
-
registerUserTools,
|
|
44
|
-
(server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
|
|
45
|
-
registerCalendarTools,
|
|
46
|
-
registerExpenseTools,
|
|
47
|
-
registerJournalTools,
|
|
48
|
-
],
|
|
52
|
+
tools,
|
|
49
53
|
banner: '[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
|
|
50
54
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getExpenseOnly } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Select the registrars exposed by this deployment.
|
|
4
|
+
*
|
|
5
|
+
* Expense-only mode is structural: omitted registrars are never handed to
|
|
6
|
+
* runMcp, so their tools cannot appear in tools/list or be invoked by name.
|
|
7
|
+
* OFW_EXPENSE_UPLOAD_ONLY implies this same registrar restriction and the
|
|
8
|
+
* expense registrar itself removes its read tools.
|
|
9
|
+
*/
|
|
10
|
+
export function selectToolRegistrars(r) {
|
|
11
|
+
if (getExpenseOnly())
|
|
12
|
+
return [r.healthcheck, r.expenses];
|
|
13
|
+
return [r.healthcheck, r.user, r.messages, r.calendar, r.expenses, r.journal];
|
|
14
|
+
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -292,11 +292,15 @@ export async function requestWrite(client, method, path, body) {
|
|
|
292
292
|
* may have landed and names how to check before any retry.
|
|
293
293
|
*/
|
|
294
294
|
export function unconfirmedWriteResponse(e, opts) {
|
|
295
|
+
// The web app is the fallback for a checkWith that names a tool. A checkWith
|
|
296
|
+
// that already sends the caller to the web app (a deployment where the list
|
|
297
|
+
// tool is not registered) needs no second mention of it.
|
|
298
|
+
const fallback = /ourfamilywizard\.com/i.test(opts.checkWith) ? '' : ' (or on ourfamilywizard.com)';
|
|
295
299
|
return jsonErrorResponse({
|
|
296
300
|
result: opts.result,
|
|
297
301
|
mayHaveLanded: true,
|
|
298
302
|
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}
|
|
303
|
+
remedy: `Do NOT retry blindly — a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith}${fallback}, and retry only once you have confirmed it did not land.`,
|
|
300
304
|
});
|
|
301
305
|
}
|
|
302
306
|
export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
|
|
@@ -8,10 +8,10 @@
|
|
|
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 { chmodSync,
|
|
11
|
+
import { chmodSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
12
12
|
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
13
13
|
import { getDefaultAttachmentsDir, getUploadDir } from '../config.js';
|
|
14
|
-
import {
|
|
14
|
+
import { assertPathWithinRoots, expandPath, fileBlob, FileWriteRefusedError, sniffMimeBytes, writeFileSafe, } from '@chrischall/mcp-utils';
|
|
15
15
|
/**
|
|
16
16
|
* True when `candidate` is strictly inside `root` (both absolute). Lexical:
|
|
17
17
|
* resolve symlinks first when that matters.
|
|
@@ -20,13 +20,6 @@ export function isWithin(root, candidate) {
|
|
|
20
20
|
const rel = relative(root, candidate);
|
|
21
21
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
22
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
|
-
}
|
|
30
23
|
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
31
24
|
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
32
25
|
const MIME_BY_EXT = {
|
|
@@ -81,20 +74,11 @@ export function normalizeMimeType(raw) {
|
|
|
81
74
|
* onto them), so the actual bytes are the authoritative signal. Returns the
|
|
82
75
|
* bare media type, or null when the bytes aren't a PNG/JPEG/GIF/WEBP.
|
|
83
76
|
*/
|
|
84
|
-
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
85
|
-
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
86
77
|
export function sniffImageMime(bytes) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (bytes.length >= 6 && bytes.toString('ascii', 0, 4) === 'GIF8')
|
|
92
|
-
return 'image/gif';
|
|
93
|
-
if (bytes.length >= 12 &&
|
|
94
|
-
bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
|
|
95
|
-
return 'image/webp';
|
|
96
|
-
}
|
|
97
|
-
return null;
|
|
78
|
+
// mcp-utils' shared magic-byte table, narrowed to what a host renders inline
|
|
79
|
+
// (it also names PDF, zip, MIDI and HEIC/MP4 — not ImageContent material).
|
|
80
|
+
const sniffed = sniffMimeBytes(bytes);
|
|
81
|
+
return sniffed !== undefined && HOST_RENDERABLE_IMAGE_MIMES.has(sniffed) ? sniffed : null;
|
|
98
82
|
}
|
|
99
83
|
/**
|
|
100
84
|
* Resolve the MIME type to report for downloaded bytes, in priority order:
|
|
@@ -129,12 +113,18 @@ export class NodeAttachmentIO {
|
|
|
129
113
|
// against the upload dir instead, and only expand a leading ~.
|
|
130
114
|
const abs = resolve(root, path.startsWith('~') ? expandPath(path) : path);
|
|
131
115
|
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.`);
|
|
116
|
+
// Lexical check first (a path that merely names somewhere else is refused
|
|
117
|
+
// even if a symlink there leads back in), then the shared real-path check.
|
|
132
118
|
if (!isWithin(root, abs))
|
|
133
119
|
throw outside;
|
|
134
120
|
const real = realpathSync(abs); // throws if missing
|
|
135
121
|
const realRoot = realpathSync(root);
|
|
136
|
-
|
|
122
|
+
try {
|
|
123
|
+
assertPathWithinRoots(real, [realRoot]);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
137
126
|
throw outside;
|
|
127
|
+
}
|
|
138
128
|
if (relative(realRoot, real).split(sep).some((segment) => segment.startsWith('.'))) {
|
|
139
129
|
throw new Error(`Refusing to upload ${abs}: hidden files and files in hidden directories (dotfiles, credential stores) are never uploaded.`);
|
|
140
130
|
}
|
|
@@ -160,7 +150,7 @@ export class NodeAttachmentIO {
|
|
|
160
150
|
return null;
|
|
161
151
|
}
|
|
162
152
|
}
|
|
163
|
-
writeDownload(dest, bytes, { root, overwrite }) {
|
|
153
|
+
async writeDownload(dest, bytes, { root, overwrite }) {
|
|
164
154
|
// The bytes are co-parent-supplied, so where they land is the security
|
|
165
155
|
// boundary. Check the REAL path of the nearest existing ancestor before
|
|
166
156
|
// creating anything, so a symlinked directory inside the root cannot carry
|
|
@@ -177,25 +167,35 @@ export class NodeAttachmentIO {
|
|
|
177
167
|
chmodSync(root, 0o700);
|
|
178
168
|
const realRoot = realpathSync(root);
|
|
179
169
|
const parent = dirname(dest);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
170
|
+
const outside = () => new Error(`Refusing to write ${dest}: it resolves outside the attachments directory (${root}).`);
|
|
171
|
+
// Real path of the nearest existing ancestor (mcp-utils assertPathWithinRoots).
|
|
172
|
+
try {
|
|
173
|
+
assertPathWithinRoots(parent, [realRoot]);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw outside();
|
|
183
177
|
}
|
|
184
178
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
185
179
|
if (overwrite) {
|
|
186
|
-
// unlink
|
|
180
|
+
// Replace by unlink + exclusive create, NOT an O_TRUNC overwrite: unlink
|
|
181
|
+
// removes a symlink (or a hard link) itself, never its target, so a link
|
|
182
|
+
// planted at `dest` can't make "replace" rewrite a file elsewhere.
|
|
187
183
|
try {
|
|
188
184
|
unlinkSync(dest);
|
|
189
185
|
}
|
|
190
186
|
catch { /* nothing there to replace */ }
|
|
191
187
|
}
|
|
192
188
|
try {
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
189
|
+
// writeFileSafe: an O_CREAT|O_EXCL|O_NOFOLLOW open, so ANY existing
|
|
190
|
+
// entry — a symlink (even dangling) included — is refused, never
|
|
191
|
+
// clobbered or followed. allowedRoots re-confines the parent at write
|
|
192
|
+
// time, closing the window since the check above (and the mkdir).
|
|
193
|
+
await writeFileSafe(dest, bytes, { mode: 0o600, allowedRoots: [realRoot] });
|
|
196
194
|
}
|
|
197
195
|
catch (e) {
|
|
198
|
-
if (e
|
|
196
|
+
if (e instanceof FileWriteRefusedError) {
|
|
197
|
+
if (e.reason === 'outside-roots')
|
|
198
|
+
throw outside();
|
|
199
199
|
throw new Error(`Refusing to overwrite ${dest}: a file already exists there. Pass force:true to replace it, or choose another saveTo.`);
|
|
200
200
|
}
|
|
201
201
|
throw e;
|