@nimbus-dev/connectors 0.4.0 → 0.5.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.
|
@@ -43,15 +43,59 @@ const listArgs = z.object({
|
|
|
43
43
|
calendar: z.string().min(1).optional(),
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
/** A C0 control (CR, LF and TAB included), DEL, or a C1 control. */
|
|
47
|
+
function isControlCode(code: number): boolean {
|
|
48
|
+
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether `value` carries a control character anywhere. Event text reaches iCalendar content
|
|
53
|
+
* lines, where CR or LF ends a line and starts a new property; the SDK's escaping turns CRLF or LF
|
|
54
|
+
* into an escaped newline but leaves a bare CR. So the input is REFUSED here rather than relying
|
|
55
|
+
* on (or adding) an escape. Nothing is stripped.
|
|
56
|
+
*/
|
|
57
|
+
export function hasControlCharacter(value: string): boolean {
|
|
58
|
+
for (let i = 0; i < value.length; i++) {
|
|
59
|
+
if (isControlCode(value.charCodeAt(i))) return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether `value` carries any whitespace (Unicode `\s`) or control character. */
|
|
65
|
+
export function hasWhitespaceOrControl(value: string): boolean {
|
|
66
|
+
return /\s/u.test(value) || hasControlCharacter(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const ICLOUD_TEXT_REFUSAL = "must not contain a line break or other control character";
|
|
70
|
+
export const ICLOUD_TIME_REFUSAL =
|
|
71
|
+
"must be a UTC time in the form YYYYMMDDTHHMMSSZ; all-day and floating times are not supported";
|
|
72
|
+
export const ICLOUD_UID_REFUSAL =
|
|
73
|
+
"must be 1 to 200 characters, each a letter, a digit, '.', '_', '@' or '-'";
|
|
74
|
+
export const ICLOUD_ATTENDEE_REFUSAL =
|
|
75
|
+
"each attendee must be one email address, with no whitespace or control character";
|
|
76
|
+
|
|
77
|
+
/** `YYYYMMDDTHHMMSSZ`: a UTC date-time and nothing else (owner ruling OR-B4-6). */
|
|
78
|
+
const UTC_DATE_TIME = /^\d{8}T\d{6}Z$/;
|
|
79
|
+
const EVENT_UID = /^[A-Za-z0-9._@-]{1,200}$/;
|
|
80
|
+
|
|
81
|
+
/** A free-text event field: refused, never cleaned, when it carries a control character. */
|
|
82
|
+
function eventText(): z.ZodString {
|
|
83
|
+
return z.string().refine((s) => !hasControlCharacter(s), ICLOUD_TEXT_REFUSAL);
|
|
84
|
+
}
|
|
85
|
+
|
|
46
86
|
const createArgs = z.object({
|
|
47
|
-
calendar:
|
|
48
|
-
summary:
|
|
49
|
-
start: z.string().
|
|
50
|
-
end: z.string().
|
|
51
|
-
description:
|
|
52
|
-
location:
|
|
53
|
-
|
|
54
|
-
|
|
87
|
+
calendar: eventText().min(1).optional(),
|
|
88
|
+
summary: eventText().min(1).max(1000),
|
|
89
|
+
start: z.string().regex(UTC_DATE_TIME, ICLOUD_TIME_REFUSAL),
|
|
90
|
+
end: z.string().regex(UTC_DATE_TIME, ICLOUD_TIME_REFUSAL),
|
|
91
|
+
description: eventText().max(10_000).optional(),
|
|
92
|
+
location: eventText().max(500).optional(),
|
|
93
|
+
// z.email() already refuses whitespace and controls today; the explicit check keeps that a
|
|
94
|
+
// stated property of THIS schema, not of zod's current email pattern.
|
|
95
|
+
attendees: z
|
|
96
|
+
.array(z.email().refine((s) => !hasWhitespaceOrControl(s), ICLOUD_ATTENDEE_REFUSAL))
|
|
97
|
+
.optional(),
|
|
98
|
+
uid: z.string().regex(EVENT_UID, ICLOUD_UID_REFUSAL).optional(),
|
|
55
99
|
});
|
|
56
100
|
|
|
57
101
|
const deleteArgs = z.object({
|
|
@@ -199,7 +243,7 @@ export function registerAppleCalendarTools(
|
|
|
199
243
|
recoverable: true,
|
|
200
244
|
scopeTargetOf: (p) => ({ kind: "calendar", value: p.calendar ?? "default" }),
|
|
201
245
|
},
|
|
202
|
-
"Create a new event in iCloud Calendar via CalDAV PUT.",
|
|
246
|
+
"Create a new event in iCloud Calendar via CalDAV PUT. start and end are UTC, in the form YYYYMMDDTHHMMSSZ (all-day and floating times are refused). summary, description, location and calendar may not contain a line break or other control character; uid, when given, is 1-200 of A-Z a-z 0-9 . _ @ -.",
|
|
203
247
|
createArgs,
|
|
204
248
|
async (parsedData) => {
|
|
205
249
|
const parsed = { success: true as const, data: parsedData };
|
|
@@ -71,6 +71,37 @@ function readPageSnapshot(res: { ok: boolean; status: number; text: string }): P
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** One entry of `confluence_page_get`'s `ancestors`: what a caller needs, not the whole page. */
|
|
75
|
+
export interface PageAncestor {
|
|
76
|
+
readonly id: string | null;
|
|
77
|
+
readonly title: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Narrow the `ancestors` Confluence expands on a page to `{ id, title }` per entry, in Confluence's
|
|
82
|
+
* order (the space's top-level page first, the page's direct parent last). An ancestor is a whole
|
|
83
|
+
* content object (links, extensions, status); a caller deciding where a page sits needs only its
|
|
84
|
+
* id, and the title is free. An entry without a string id keeps its place with `id: null` rather
|
|
85
|
+
* than being dropped, so the chain is never shorter than Confluence said. A reply with no
|
|
86
|
+
* `ancestors` array is returned as it came: absent stays absent, never an invented `[]`.
|
|
87
|
+
*/
|
|
88
|
+
export function withAncestorSummaries(page: unknown): unknown {
|
|
89
|
+
if (page === null || typeof page !== "object" || Array.isArray(page)) return page;
|
|
90
|
+
const obj = page as Record<string, unknown>;
|
|
91
|
+
const ancestors = obj["ancestors"];
|
|
92
|
+
if (!Array.isArray(ancestors)) return page;
|
|
93
|
+
const summaries: PageAncestor[] = ancestors.map((a: unknown) => {
|
|
94
|
+
const entry = a !== null && typeof a === "object" ? (a as Record<string, unknown>) : {};
|
|
95
|
+
const id = entry["id"];
|
|
96
|
+
const title = entry["title"];
|
|
97
|
+
return {
|
|
98
|
+
id: typeof id === "string" ? id : null,
|
|
99
|
+
title: typeof title === "string" ? title : null,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
return { ...obj, ancestors: summaries };
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
75
106
|
export const CONFLUENCE_TOOL_NAMES = [
|
|
76
107
|
"confluence_space_list",
|
|
@@ -150,14 +181,23 @@ export function registerConfluenceTools(
|
|
|
150
181
|
|
|
151
182
|
reg(
|
|
152
183
|
"confluence_page_get",
|
|
153
|
-
"Get a Confluence page with body.storage (GET /content/{id}).",
|
|
184
|
+
"Get a Confluence page with body.storage (GET /content/{id}). ancestors lists the pages above it as { id, title }, the top-level page first and the direct parent last.",
|
|
154
185
|
confluencePageIdSchema,
|
|
155
186
|
async (parsed) => {
|
|
156
187
|
const id = encodeURIComponent(parsed.pageId);
|
|
157
188
|
const res = await confFetch(
|
|
158
|
-
`/content/${id}?expand=body.storage,version,history.lastUpdated,space`,
|
|
189
|
+
`/content/${id}?expand=body.storage,version,history.lastUpdated,space,ancestors`,
|
|
159
190
|
);
|
|
160
|
-
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
throw new Error(`Confluence ${String(res.status)}: ${res.text.slice(0, 400)}`);
|
|
193
|
+
}
|
|
194
|
+
let page: unknown;
|
|
195
|
+
try {
|
|
196
|
+
page = JSON.parse(res.text) as unknown;
|
|
197
|
+
} catch {
|
|
198
|
+
throw new Error("Confluence: invalid JSON response");
|
|
199
|
+
}
|
|
200
|
+
return mcpJsonResult(withAncestorSummaries(page));
|
|
161
201
|
},
|
|
162
202
|
);
|
|
163
203
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "com.nimbus.obsidian",
|
|
3
3
|
"displayName": "Obsidian",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "Local Obsidian vault connector (notes, backlinks). No network. Vault paths come from [[filesystem.roots]] and are merged into permissions.filesystem at spawn time by the Gateway. obsidian_append_to_daily_note requires HITL.",
|
|
5
|
+
"description": "Local Obsidian vault connector (notes, backlinks, daily notes). No network. Vault paths come from [[filesystem.roots]] and are merged into permissions.filesystem.read at spawn time by the Gateway, which may also add the vaults to permissions.filesystem.write at spawn (only while the owner has approved obsidian_append_to_daily_note for the model). obsidian_append_to_daily_note requires HITL; obsidian_daily_note_get is a read.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import {
|
|
3
3
|
closeSync,
|
|
4
4
|
fstatSync,
|
|
5
|
+
lstatSync,
|
|
5
6
|
mkdirSync,
|
|
6
7
|
openSync,
|
|
7
8
|
readdirSync,
|
|
8
9
|
readFileSync,
|
|
9
10
|
readSync,
|
|
11
|
+
realpathSync,
|
|
10
12
|
statSync,
|
|
11
13
|
writeSync,
|
|
12
14
|
} from "node:fs";
|
|
@@ -54,6 +56,93 @@ function assertWithinVault(vaultRoot: string, relPath: string): string {
|
|
|
54
56
|
return candidate;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/** Whether anything, a dangling link included, sits at `p`. */
|
|
60
|
+
function pathEntryExists(p: string): boolean {
|
|
61
|
+
try {
|
|
62
|
+
lstatSync(p);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Refuse `abs` (a path `assertWithinVault` already accepted) when the place it really names is
|
|
71
|
+
* outside the vault: a folder or note that is, or passes through, a symlink or junction out of the
|
|
72
|
+
* vault. `assertWithinVault` compares strings, so it cannot see a link.
|
|
73
|
+
*
|
|
74
|
+
* The note, and the folders above it, may not exist yet (the append creates them), so the check
|
|
75
|
+
* resolves the deepest part of the path that does exist; whatever is created below it is created
|
|
76
|
+
* inside it. A dangling link cannot be resolved and is refused. The check and the later open are
|
|
77
|
+
* two system calls, so a link swapped in between them is not seen here: the gateway's sandbox,
|
|
78
|
+
* which grants only the vaults, is the boundary (stated bound).
|
|
79
|
+
*/
|
|
80
|
+
export function assertRealpathWithinVault(vaultRoot: string, abs: string, relPath: string): void {
|
|
81
|
+
const realRoot = realpathSync(resolve(vaultRoot));
|
|
82
|
+
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
|
|
83
|
+
let probe = abs;
|
|
84
|
+
while (!pathEntryExists(probe)) {
|
|
85
|
+
const parent = dirname(probe);
|
|
86
|
+
if (parent === probe) break;
|
|
87
|
+
probe = parent;
|
|
88
|
+
}
|
|
89
|
+
let real: string;
|
|
90
|
+
try {
|
|
91
|
+
real = realpathSync(probe);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`Path escapes vault: ${relPath}`);
|
|
94
|
+
}
|
|
95
|
+
if (real !== realRoot && !real.startsWith(prefix)) {
|
|
96
|
+
throw new Error(`Path escapes vault: ${relPath}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How much of the end of a daily note `obsidian_daily_note_get` returns. */
|
|
101
|
+
export const DAILY_NOTE_TAIL_BYTES = 16 * 1024;
|
|
102
|
+
|
|
103
|
+
/** The positioned-read signature of `readSync`, injectable so a test can count what is read. */
|
|
104
|
+
export type PositionedRead = (
|
|
105
|
+
fd: number,
|
|
106
|
+
buffer: Buffer,
|
|
107
|
+
offset: number,
|
|
108
|
+
length: number,
|
|
109
|
+
position: number,
|
|
110
|
+
) => number;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The size of the file at `abs` and its last `maxBytes` bytes, decoded as UTF-8.
|
|
114
|
+
*
|
|
115
|
+
* Positioned reads of at most `maxBytes` in all, never the whole file. When the window starts
|
|
116
|
+
* inside the file, the UTF-8 continuation bytes it begins with (the rest of a character the window
|
|
117
|
+
* cut) are dropped, so the tail starts on a character boundary.
|
|
118
|
+
*/
|
|
119
|
+
export function readUtf8Tail(
|
|
120
|
+
abs: string,
|
|
121
|
+
maxBytes: number,
|
|
122
|
+
read: PositionedRead = readSync,
|
|
123
|
+
): { size: number; tail: string } {
|
|
124
|
+
const fd = openSync(abs, "r");
|
|
125
|
+
try {
|
|
126
|
+
const size = fstatSync(fd).size;
|
|
127
|
+
const start = Math.max(0, size - maxBytes);
|
|
128
|
+
const length = size - start;
|
|
129
|
+
const buf = Buffer.alloc(length);
|
|
130
|
+
let got = 0;
|
|
131
|
+
while (got < length) {
|
|
132
|
+
const n = read(fd, buf, got, length - got, start + got);
|
|
133
|
+
if (n <= 0) break;
|
|
134
|
+
got += n;
|
|
135
|
+
}
|
|
136
|
+
let skip = 0;
|
|
137
|
+
if (start > 0) {
|
|
138
|
+
while (skip < 3 && skip < got && ((buf[skip] ?? 0) & 0xc0) === 0x80) skip++;
|
|
139
|
+
}
|
|
140
|
+
return { size, tail: buf.subarray(skip, got).toString("utf8") };
|
|
141
|
+
} finally {
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
57
146
|
function loadVaultPaths(): readonly string[] {
|
|
58
147
|
const raw = requireProcessEnv("OBSIDIAN_VAULT_PATHS_JSON");
|
|
59
148
|
let parsed: unknown;
|
|
@@ -98,7 +187,10 @@ function walkForVaults(dir: string, out: VaultEntry[]): void {
|
|
|
98
187
|
const sub = join(dir, e);
|
|
99
188
|
let isDir = false;
|
|
100
189
|
try {
|
|
101
|
-
|
|
190
|
+
// lstat, not stat: stat follows a link, so its isSymbolicLink() was always false and
|
|
191
|
+
// discovery used to find a vault through a symlink or junction outside the configured root.
|
|
192
|
+
// Every link below a root is skipped, so a discovered vault is physically under its root.
|
|
193
|
+
const st = lstatSync(sub);
|
|
102
194
|
isDir = st.isDirectory() && !st.isSymbolicLink();
|
|
103
195
|
} catch {
|
|
104
196
|
continue;
|
|
@@ -128,7 +220,9 @@ function walkNotes(currentDir: string, vaultRoot: string, out: string[]): void {
|
|
|
128
220
|
let isFile = false;
|
|
129
221
|
let isDir = false;
|
|
130
222
|
try {
|
|
131
|
-
|
|
223
|
+
// lstat, not stat: stat follows a link, so its isSymbolicLink() is always false and the
|
|
224
|
+
// walk used to list notes through a symlink or junction, out of the vault included.
|
|
225
|
+
const st = lstatSync(full);
|
|
132
226
|
if (st.isSymbolicLink()) continue;
|
|
133
227
|
isFile = st.isFile();
|
|
134
228
|
isDir = st.isDirectory();
|
|
@@ -151,6 +245,7 @@ function readNote(
|
|
|
151
245
|
relPath: string,
|
|
152
246
|
): { title: string; body: string; raw: string } {
|
|
153
247
|
const abs = assertWithinVault(vaultRoot, relPath);
|
|
248
|
+
assertRealpathWithinVault(vaultRoot, abs, relPath);
|
|
154
249
|
const raw = readFileSync(abs, "utf8");
|
|
155
250
|
const m = FRONTMATTER_RE.exec(raw);
|
|
156
251
|
const body = m === null ? raw : raw.slice(m[0].length);
|
|
@@ -193,6 +288,7 @@ export const OBSIDIAN_TOOL_NAMES = [
|
|
|
193
288
|
"obsidian_get",
|
|
194
289
|
"obsidian_search",
|
|
195
290
|
"obsidian_append_to_daily_note",
|
|
291
|
+
"obsidian_daily_note_get",
|
|
196
292
|
] as const;
|
|
197
293
|
|
|
198
294
|
/** The date tokens an Obsidian daily-note filename format may contain. */
|
|
@@ -400,6 +496,28 @@ export function registerObsidianTools(
|
|
|
400
496
|
}),
|
|
401
497
|
);
|
|
402
498
|
|
|
499
|
+
/**
|
|
500
|
+
* The vault and the daily note a daily-note tool names: ONE resolution, shared by the append and
|
|
501
|
+
* the read, so the read shows exactly the note the append would write. String containment
|
|
502
|
+
* (`assertWithinVault`) and then realpath containment (`assertRealpathWithinVault`).
|
|
503
|
+
*/
|
|
504
|
+
function resolveDailyNote(parsed: { vault_id: string; date_iso?: string | undefined }): {
|
|
505
|
+
v: VaultEntry;
|
|
506
|
+
rel: string;
|
|
507
|
+
abs: string;
|
|
508
|
+
} {
|
|
509
|
+
const v = findVaultByIdOrPathPrefix(VAULTS, parsed.vault_id);
|
|
510
|
+
if (v === undefined) {
|
|
511
|
+
throw new Error("Unknown vault_id");
|
|
512
|
+
}
|
|
513
|
+
const date =
|
|
514
|
+
parsed.date_iso === undefined ? new Date() : new Date(`${parsed.date_iso}T00:00:00Z`);
|
|
515
|
+
const rel = resolveDailyNoteRelativePath(v.root, date);
|
|
516
|
+
const abs = assertWithinVault(v.root, rel);
|
|
517
|
+
assertRealpathWithinVault(v.root, abs, rel);
|
|
518
|
+
return { v, rel, abs };
|
|
519
|
+
}
|
|
520
|
+
|
|
403
521
|
const appendDailyNoteSchema = z.object({
|
|
404
522
|
vault_id: z.string().min(1),
|
|
405
523
|
content: z.string().min(1),
|
|
@@ -420,15 +538,10 @@ export function registerObsidianTools(
|
|
|
420
538
|
"Append text to today's Obsidian daily note. Creates the file if it does not exist. Always appends — never overwrites. Adds a leading newline when the existing file does not end in one. Requires HITL `obsidian.note.append`.",
|
|
421
539
|
appendDailyNoteSchema,
|
|
422
540
|
syncToolHandler((parsed) => {
|
|
423
|
-
const v =
|
|
424
|
-
if (v === undefined) {
|
|
425
|
-
throw new Error("Unknown vault_id");
|
|
426
|
-
}
|
|
427
|
-
const date =
|
|
428
|
-
parsed.date_iso === undefined ? new Date() : new Date(`${parsed.date_iso}T00:00:00Z`);
|
|
429
|
-
const rel = resolveDailyNoteRelativePath(v.root, date);
|
|
430
|
-
const abs = assertWithinVault(v.root, rel);
|
|
541
|
+
const { v, rel, abs } = resolveDailyNote(parsed);
|
|
431
542
|
mkdirSync(dirname(abs), { recursive: true });
|
|
543
|
+
// Again, now the folders exist: what was created must still be inside the vault.
|
|
544
|
+
assertRealpathWithinVault(v.root, abs, rel);
|
|
432
545
|
|
|
433
546
|
const fd = openSync(abs, "a+");
|
|
434
547
|
let bytes = 0;
|
|
@@ -463,4 +576,39 @@ export function registerObsidianTools(
|
|
|
463
576
|
});
|
|
464
577
|
}),
|
|
465
578
|
);
|
|
579
|
+
|
|
580
|
+
const dailyNoteGetSchema = z.object({
|
|
581
|
+
vault_id: z.string().min(1),
|
|
582
|
+
date_iso: z
|
|
583
|
+
.string()
|
|
584
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
585
|
+
.optional(),
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
reg(
|
|
589
|
+
"obsidian_daily_note_get",
|
|
590
|
+
`Read the end of the Obsidian daily note that obsidian_append_to_daily_note would write for the same vault_id and date_iso (today in UTC when omitted). Returns { vault_id, vault_name, path, exists, size_bytes, tail }: tail is at most the last ${String(DAILY_NOTE_TAIL_BYTES)} bytes, starting on a character boundary; a missing note is exists: false, size_bytes: 0, tail "". Read-only.`,
|
|
591
|
+
dailyNoteGetSchema,
|
|
592
|
+
syncToolHandler((parsed) => {
|
|
593
|
+
const { v, rel, abs } = resolveDailyNote(parsed);
|
|
594
|
+
let exists = false;
|
|
595
|
+
let size = 0;
|
|
596
|
+
let tail = "";
|
|
597
|
+
if (pathEntryExists(abs)) {
|
|
598
|
+
if (!statSync(abs).isFile()) {
|
|
599
|
+
throw new Error(`Daily note is not a file: ${rel}`);
|
|
600
|
+
}
|
|
601
|
+
exists = true;
|
|
602
|
+
({ size, tail } = readUtf8Tail(abs, DAILY_NOTE_TAIL_BYTES));
|
|
603
|
+
}
|
|
604
|
+
return jsonResult({
|
|
605
|
+
vault_id: v.id,
|
|
606
|
+
vault_name: v.name,
|
|
607
|
+
path: rel,
|
|
608
|
+
exists,
|
|
609
|
+
size_bytes: size,
|
|
610
|
+
tail,
|
|
611
|
+
});
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
466
614
|
}
|