@rubytech/create-maxy-code 0.1.457 → 0.1.459
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/package.json +1 -1
- package/payload/platform/lib/mcp-lifeline/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/mcp-lifeline/dist/index.js +22 -1
- package/payload/platform/lib/mcp-lifeline/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-lifeline/src/__tests__/lifeline.test.ts +26 -0
- package/payload/platform/lib/mcp-lifeline/src/index.ts +24 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +322 -2
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts +2 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +59 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +12 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +156 -26
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-credential.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-credential.js +8 -2
- package/payload/platform/lib/storage-broker/dist/house-credential.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
- package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.js +1 -0
- package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +53 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.js +72 -0
- package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -0
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +362 -3
- package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +76 -0
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +230 -44
- package/payload/platform/lib/storage-broker/src/house-credential.ts +8 -2
- package/payload/platform/lib/storage-broker/src/index.ts +1 -0
- package/payload/platform/lib/storage-broker/src/object-limits.ts +75 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -5
- package/payload/platform/plugins/cloudflare/bin/__tests__/cf-token.test.sh +145 -4
- package/payload/platform/plugins/cloudflare/bin/cf-token.sh +61 -3
- package/payload/platform/plugins/cloudflare/references/api.md +1 -1
- package/payload/platform/plugins/cloudflare/skills/cloudflare/SKILL.md +1 -1
- package/payload/platform/plugins/docs/references/outlook-guide.md +4 -4
- package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/outlook/PLUGIN.md +2 -2
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js +138 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js +121 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js +102 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +16 -3
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js +108 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js +209 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/index.js +10 -7
- package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts +37 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js +79 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +42 -15
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +32 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts +8 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.js +4 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/message.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +31 -8
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js +40 -13
- package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts +9 -5
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js +12 -8
- package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.js +3 -5
- package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +36 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +56 -9
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts +13 -2
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +18 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +3 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js +2 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +3 -20
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts +27 -3
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +53 -8
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
- package/payload/platform/plugins/outlook/references/graph-surfaces.md +41 -6
- package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +22 -5
- package/payload/platform/plugins/storage-broker/PLUGIN.md +41 -2
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts +2 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js +85 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts +2 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js +77 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts +12 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js +107 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/index.js +86 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts +11 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts.map +1 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js +35 -0
- package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js.map +1 -0
- package/payload/platform/scripts/logs-read-jsonl.test.sh +118 -0
- package/payload/platform/scripts/logs-read.sh +63 -9
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +4 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/server/{chunk-F4D35LKL.js → chunk-POBXIHOF.js} +34 -0
- package/payload/server/{chunk-SDYRKIYY.js → chunk-Q6W4U6HL.js} +153 -27
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +206 -6
- package/payload/server/{src-554BYJMN.js → src-3I2RYZFB.js} +9 -1
|
@@ -1,25 +1,70 @@
|
|
|
1
1
|
import { postGraph } from "../lib/graph-client.js";
|
|
2
2
|
import { buildMessage } from "../lib/message.js";
|
|
3
|
+
import { attachFilesToDraft, resolveAttachmentsOrRefuse } from "../lib/attach.js";
|
|
3
4
|
import { log } from "../lib/log.js";
|
|
4
5
|
/**
|
|
5
|
-
* Send a message via
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Send a message via Microsoft Graph.
|
|
7
|
+
*
|
|
8
|
+
* Two transports, chosen by whether the message carries a file:
|
|
9
|
+
*
|
|
10
|
+
* - no attachments → `POST /me/sendMail`. One call, atomic.
|
|
11
|
+
* - attachments → `POST /me/messages` (draft) → per-file attach →
|
|
12
|
+
* `POST /me/messages/{id}/send`, reusing mail-reply's
|
|
13
|
+
* proven order.
|
|
14
|
+
*
|
|
15
|
+
* The switch is forced, not stylistic: `/me/sendMail` carries attachments inline
|
|
16
|
+
* in the request body and is bounded well below the plugin's 25 MB per-file cap,
|
|
17
|
+
* so a large file can only reach the wire through a draft's upload session.
|
|
18
|
+
* The `op: "transport"` line names the route actually taken — a `route=sendMail`
|
|
19
|
+
* line with `attachments>0` is the mis-wire signature and must never appear.
|
|
20
|
+
*
|
|
21
|
+
* `messageId` is null on BOTH routes. `/me/sendMail` returns 202 with no body,
|
|
22
|
+
* and on the draft route the draft id is consumed by `/send` — returning it
|
|
23
|
+
* would hand back a handle that 404s on the next call (Task 1688). Success is
|
|
24
|
+
* asserted on the Graph status, which `writeGraph` already enforces by throwing
|
|
25
|
+
* on any non-2xx; the literal status is logged rather than an assumed 202.
|
|
26
|
+
*
|
|
27
|
+
* The draft route is NOT atomic: an attach or send failing Graph-side leaves a
|
|
28
|
+
* draft in the Drafts folder, the same exposure mail-reply carries. It is
|
|
29
|
+
* operator-visible there, and greppable as an `op:"transport" route=draft-send`
|
|
30
|
+
* line with no terminal `sent` line. Paths validate before any Graph write, so
|
|
31
|
+
* the common failure — a bad path — still orphans nothing.
|
|
8
32
|
*/
|
|
9
33
|
export async function runMailSend(config, input) {
|
|
10
34
|
const account = config.accountId;
|
|
11
35
|
log({ event: "send-request", account, to: input.to.length, subjLen: input.subject.length });
|
|
36
|
+
// Before any Graph write: a bad path must leave nothing behind.
|
|
37
|
+
const files = resolveAttachmentsOrRefuse(account, input.attachments, "mail-send");
|
|
12
38
|
const message = buildMessage(input);
|
|
13
39
|
const recipients = input.to.length + (input.cc?.length ?? 0) + (input.bcc?.length ?? 0);
|
|
14
40
|
log({ event: "send-args", account, recipients, hasBody: input.body.length > 0 });
|
|
41
|
+
const route = files.length > 0 ? "draft-send" : "sendMail";
|
|
42
|
+
log({ event: "mail-send", op: "transport", route, attachments: files.length });
|
|
15
43
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
44
|
+
if (files.length === 0) {
|
|
45
|
+
const { status, json } = await postGraph(config, "/me/sendMail", { message, saveToSentItems: true }, { tool: "mail-send" });
|
|
46
|
+
const messageId = json?.id ?? null;
|
|
47
|
+
log({ event: "sent", account, status, messageId, route });
|
|
48
|
+
return { status: "sent", httpStatus: status, messageId, attachments: [] };
|
|
49
|
+
}
|
|
50
|
+
const { json } = await postGraph(config, "/me/messages", message, { tool: "mail-send" });
|
|
51
|
+
const draftId = json?.id;
|
|
52
|
+
if (!draftId) {
|
|
53
|
+
throw new Error("outlook-mail-send: Graph created the draft but returned no id.");
|
|
54
|
+
}
|
|
55
|
+
// The orphan diagnostic, mirroring draft.ts's `draft-created` / draft-send.ts's
|
|
56
|
+
// `draft-sent` pairing. Emitted the moment a draft exists, because from here on
|
|
57
|
+
// a failure strands it: this id is the only handle the operator has to find it.
|
|
58
|
+
// Its absence also proves the CREATE itself failed, which strands nothing —
|
|
59
|
+
// without it, those two outcomes are indistinguishable in the log.
|
|
60
|
+
log({ event: "mail-send", op: "draft-created", account, id: draftId, route });
|
|
61
|
+
await attachFilesToDraft(config, draftId, files, "mail-send");
|
|
62
|
+
const { status } = await postGraph(config, `/me/messages/${encodeURIComponent(draftId)}/send`, undefined, { tool: "mail-send" });
|
|
63
|
+
log({ event: "sent", account, status, messageId: null, route });
|
|
64
|
+
return { status: "sent", httpStatus: status, messageId: null, attachments: files.map((f) => f.name) };
|
|
20
65
|
}
|
|
21
66
|
catch (err) {
|
|
22
|
-
log({ event: "send-failed", account, error: err.message });
|
|
67
|
+
log({ event: "send-failed", account, route, error: err.message });
|
|
23
68
|
throw err;
|
|
24
69
|
}
|
|
25
70
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mail-send.js","sourceRoot":"","sources":["../../src/tools/mail-send.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAqB,MAAM,mBAAmB,CAAC;AACpE,OAAO,EAAE,GAAG,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"mail-send.js","sourceRoot":"","sources":["../../src/tools/mail-send.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Microsoft Graph endpoints used by each tool, with the response shape and the fields selected.
|
|
4
4
|
|
|
5
|
+
## Item id contract
|
|
6
|
+
|
|
7
|
+
Outlook item ids are **not** stable by default. Graph rotates an item's id when the item is moved or re-saved — including by the operator's own mail client, which no code here controls. A rotated id returns `404 ErrorItemNotFound`, so a handle stored moments earlier can stop resolving with nothing wrong on our side. This is exactly how a live draft appeared to "vanish" from an operator's Drafts folder: our `PATCH` returned `200`, the client rotated the id, and the next call by that id 404'd.
|
|
8
|
+
|
|
9
|
+
The plugin therefore opts into **immutable ids**, sending `Prefer: IdType="ImmutableId"` on every request. It is set once, in `createGraphClient` — the single factory behind every Graph call — so every tool inherits it.
|
|
10
|
+
|
|
11
|
+
**This is plugin-wide by necessity, not preference.** The immutable and default namespaces carry *different values for the same item*, so an id produced by a request that omitted the header cannot address that item on a request that sent it. Never add or remove the header per tool or per call. Two `fetch()` calls sit outside the factory and are correctly unaffected: `account-register`'s `/me` lookup (a directory id, not an Outlook item id) and the attachment upload-session `PUT` (a pre-authenticated URL, not a Graph resource path).
|
|
12
|
+
|
|
13
|
+
A per-request `.header()` (`outlook-mail-search`'s `ConsistencyLevel`) merges with the central header rather than displacing it — asserted in `graph-client.test.ts`, because a silent drop there would leave search alone emitting regular ids. A per-request header of the *same name* is a different matter: `Prefer` is also how Graph carries `outlook.timezone` and `odata.maxpagesize`, and setting it per-call would replace the id opt-in for that call. `Prefer` is comma-separated — append to `IMMUTABLE_ID_PREFER`, never set `Prefer` alone.
|
|
14
|
+
|
|
15
|
+
**Across a plugin restart**, ids and opaque cursors (`nextCursor`) an agent is still holding were minted in the other namespace and will not resolve. This self-heals on the next list or create and affects one conversation; nothing persists a Graph id today.
|
|
16
|
+
|
|
5
17
|
## outlook-mail-list
|
|
6
18
|
|
|
7
19
|
**Endpoint (Inbox):** `GET /me/mailFolders/Inbox/messages?$top={N}&$orderby=receivedDateTime DESC&$select=id,subject,from,receivedDateTime,bodyPreview,isRead,hasAttachments`
|
|
@@ -120,13 +132,21 @@ Recoverable — moves to Deleted Items, never hard-deletes or expunges. An id al
|
|
|
120
132
|
|
|
121
133
|
## outlook-draft-edit
|
|
122
134
|
|
|
123
|
-
**
|
|
135
|
+
**Endpoints (in order):**
|
|
136
|
+
1. `PATCH /me/messages/{draftId}` (`ResponseType.RAW`; success asserted on HTTP 200) with only the supplied fields (`subject`, `body` → `{ contentType, content }`, `toRecipients`, `ccRecipients`, `bccRecipients`). Graph drafts are mutable, so no delete-and-reappend. **Skipped entirely** when the call supplies attachments and no patchable field. A PATCH on a sent (non-draft) message returns a Graph error surfaced unchanged.
|
|
137
|
+
2. Per file, by size — identical to `outlook-mail-reply` step 3 (shared helper). Attachments are **additive**: files are added to whatever the draft already carries. Removing or replacing an existing attachment is not supported.
|
|
138
|
+
|
|
139
|
+
At least one patchable field **or** one attachment is required. Attachment paths are validated before step 1, so a bad path leaves the draft untouched rather than half-edited.
|
|
124
140
|
|
|
125
|
-
**
|
|
141
|
+
Step 2 targets the id step 1 **reported**, not the caller's `draftId`. Ids are immutable plugin-wide so the two normally match, but a rotated id means the caller's handle no longer addresses the draft — see [Item id contract](#item-id-contract).
|
|
142
|
+
|
|
143
|
+
**Returned shape:** `{ draftId: string; updated: true; attachments: string[] }` — `draftId` is the id Graph reported for the updated draft, which is authoritative over the id passed in. The tool refuses rather than report `updated: true` for an id it has not read back. An attachments-only edit issues no PATCH, so it reports the caller's id.
|
|
126
144
|
|
|
127
145
|
## outlook-mail-send
|
|
128
146
|
|
|
129
|
-
|
|
147
|
+
Two transports, chosen by whether the message carries a file. The `op: "transport"` log line names the route actually taken.
|
|
148
|
+
|
|
149
|
+
**Route A — no attachments. Endpoint:** `POST /me/sendMail`
|
|
130
150
|
|
|
131
151
|
**Request body:**
|
|
132
152
|
```json
|
|
@@ -144,13 +164,28 @@ Recoverable — moves to Deleted Items, never hard-deletes or expunges. An id al
|
|
|
144
164
|
|
|
145
165
|
**Success:** HTTP `202 Accepted`, empty body. No message id is returned. The tool asserts on the 202 — an empty body is not proof of send.
|
|
146
166
|
|
|
167
|
+
**Route B — with attachments. Endpoints (in order):**
|
|
168
|
+
1. `POST /me/messages` with the `message` object above (no `saveToSentItems` wrapper) → 201 with the created draft; `id` is used for steps 2-3 only.
|
|
169
|
+
2. Per file, by size — identical to `outlook-mail-reply` step 3 (shared helper).
|
|
170
|
+
3. `POST /me/messages/{draftId}/send` → 202. Graph consumes the draft and saves to Sent Items.
|
|
171
|
+
|
|
172
|
+
The switch is forced, not stylistic: `/me/sendMail` carries attachments inline in the request body and is bounded well below the plugin's 25 MB per-file cap, so a large file can only reach the wire through a draft's upload session. A `route=sendMail` line with `attachments>0` is the mis-wire signature and must never appear.
|
|
173
|
+
|
|
174
|
+
Route B is **not atomic**, unlike route A: an attach or send failing Graph-side leaves a draft in the Drafts folder — the same exposure `outlook-mail-reply` carries, and not cleaned up here either. Attachment paths are validated before step 1, so the common failure (a bad path) leaves nothing behind.
|
|
175
|
+
|
|
176
|
+
**Finding an orphan.** Step 1 emits `op:"draft-created" id=<draftId>` the moment a draft exists, mirroring `outlook-draft`'s `draft-created` / `outlook-draft-send`'s `draft-sent` pairing. The orphan signature is a `draft-created` line with **no** terminal `sent` line, and the `id` on it is the handle needed to go delete the draft. The `op:"transport"` line alone is not the signature: it is emitted before the create, so a transport line with no `draft-created` means the create itself failed and stranded nothing.
|
|
177
|
+
|
|
178
|
+
**Returned shape:** `{ status: "sent"; httpStatus: number; messageId: string | null; attachments: string[] }` — `messageId` is `null` on **both** routes. Route A returns no body; on route B the draft id is consumed by `/send` and would 404 if handed back — see [Item id contract](#item-id-contract).
|
|
179
|
+
|
|
147
180
|
## outlook-draft
|
|
148
181
|
|
|
149
|
-
**
|
|
182
|
+
**Endpoints (in order):**
|
|
183
|
+
1. `POST /me/messages` with the `message` object above (no `saveToSentItems` wrapper).
|
|
184
|
+
2. Per file, by size — identical to `outlook-mail-reply` step 3 (shared helper). Attachment paths are validated before step 1, so a bad path leaves no orphan draft.
|
|
150
185
|
|
|
151
|
-
**
|
|
186
|
+
**Success:** HTTP `201 Created` with the created message JSON; `id` is the handle passed to `outlook-draft-send`, and the returned draft already carries the files. It is durable only because the plugin opts into immutable ids — see [Item id contract](#item-id-contract).
|
|
152
187
|
|
|
153
|
-
**
|
|
188
|
+
**Returned shape:** `{ draftId: string; attachments: string[] }`.
|
|
154
189
|
|
|
155
190
|
## outlook-draft-send
|
|
156
191
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: outlook
|
|
3
|
-
description: "Microsoft 365 / Outlook.com via Microsoft Graph. Mail is read (list, search, full-body), reply, delete, and compose (draft, draft-edit, send); contacts are read-only; the calendar is read plus control. Use when the user asks about their Outlook mail, calendar, or contacts, wants to read a full message, reply, delete, send or draft a message, or asks to book, move, cancel, or accept a calendar event. First-time use requires outlook-account-register plus outlook-account-register-poll; subsequent use is transparent. Tools: outlook-mail-list / outlook-mail-search (inbox triage, ~255-char preview, date window + nextCursor paging), outlook-mail-fetch-body (full body of one message), outlook-mail-reply (in-thread reply with attachments), outlook-mail-delete (move to Deleted Items), outlook-mail-otp-extract (poll for a one-time code), outlook-mail-send / outlook-draft / outlook-draft-edit / outlook-draft-send (compose, edit, send), outlook-calendar-list / outlook-calendar-event (read calendar), outlook-calendar-create / outlook-calendar-update / outlook-calendar-cancel / outlook-calendar-respond / outlook-calendar-freebusy (control calendar), outlook-contacts-list (contacts), outlook-mailbox-info (health), outlook-account-register / outlook-account-register-poll (device-code register)."
|
|
3
|
+
description: "Microsoft 365 / Outlook.com via Microsoft Graph. Mail is read (list, search, full-body), reply, delete, and compose (draft, draft-edit, send); contacts are read-only; the calendar is read plus control. Use when the user asks about their Outlook mail, calendar, or contacts, wants to read a full message, reply, delete, send or draft a message, or asks to book, move, cancel, or accept a calendar event. First-time use requires outlook-account-register plus outlook-account-register-poll; subsequent use is transparent. Tools: outlook-mail-list / outlook-mail-search (inbox triage, ~255-char preview, date window + nextCursor paging), outlook-mail-fetch-body (full body of one message), outlook-mail-reply (in-thread reply with attachments), outlook-mail-delete (move to Deleted Items), outlook-mail-otp-extract (poll for a one-time code), outlook-mail-send / outlook-draft / outlook-draft-edit / outlook-draft-send (compose, edit, send, with attachments), outlook-calendar-list / outlook-calendar-event (read calendar), outlook-calendar-create / outlook-calendar-update / outlook-calendar-cancel / outlook-calendar-respond / outlook-calendar-freebusy (control calendar), outlook-contacts-list (contacts), outlook-mailbox-info (health), outlook-account-register / outlook-account-register-poll (device-code register)."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Outlook (Microsoft Graph)
|
|
@@ -50,7 +50,7 @@ The revoke path below is scoped to one specific, visible failure. It is not a ge
|
|
|
50
50
|
## Reading mail
|
|
51
51
|
|
|
52
52
|
- `outlook-mail-list` — mail newest-first, default top=25, folder=Inbox. Returns Microsoft's short `bodyPreview` (~255 chars) for cheap triage. Add `since`/`before` (ISO date-times) for a date window. To reach mail older than the newest page, pass the response's `nextCursor` back as `cursor` — repeat until `nextCursor` is null. Filter by sender or subject via `outlook-mail-search`.
|
|
53
|
-
- `outlook-mail-search query=<keyword>` — Microsoft Graph `$search` (KQL) over the mailbox; same preview-only shape as the list.
|
|
53
|
+
- `outlook-mail-search query=<keyword>` — Microsoft Graph `$search` (KQL) over the mailbox; same preview-only shape as the list. Filters go **inside the `query` string** as KQL text, not as tool parameters: `query="from:sam@example.com"`, `query="subject:invoice received>=2024-01-01"`, or free text. The bare `from:`/`to:` form is KQL syntax that lives inside that one string; it is not how recipients are written anywhere else in this plugin (see Sending and drafting, where `to` is a JSON array of quoted strings). Page with `nextCursor` → `cursor` like the list. (Graph forbids `$search`+date-`$filter`, so a strict date window is on `outlook-mail-list`.)
|
|
54
54
|
- `outlook-mail-fetch-body messageId=<id>` — the COMPLETE body of one message (HTML decoded to text, no preview cap). This is the full-read path: `outlook-mail-list` / `outlook-mail-search` return previews only, so when you must read or act on a message's actual content, fetch the body by its id. Also returns the envelope (from, to, cc, subject, date, conversationId).
|
|
55
55
|
- `outlook-mail-attachment messageId=<id>` — LIST a message's attachments (`id`, `name`, `contentType`, `size`, `isInline`, `kind`). `outlook-mail-list` / `outlook-mail-search` / `outlook-mail-fetch-body` all report `hasAttachments`; when it is true, call this to see what is attached.
|
|
56
56
|
- `outlook-mail-attachment messageId=<id> attachmentId=<id>` — DOWNLOAD one file attachment. The bytes are saved under the account's `uploads/outlook` folder and the absolute path is returned, ready for the Read tool, `SendUserFile`, or an `outlook-mail-reply` attachment. Only file attachments download: `item` and `reference` kinds are refused, as are empty payloads and anything over 25 MB. Needs `Mail.Read`.
|
|
@@ -66,9 +66,26 @@ The revoke path below is scoped to one specific, visible failure. It is not a ge
|
|
|
66
66
|
|
|
67
67
|
## Sending and drafting
|
|
68
68
|
|
|
69
|
-
- `outlook-mail-send` — send a message. `to` is an array of addresses (at least one); `cc`/`bcc` optional arrays; `subject`; `body`; `isHtml` true for an HTML body, otherwise plain text. A copy is saved to Sent Items. Success is confirmed on Graph's 202 response.
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
- `outlook-mail-send` — send a message. `to` is an array of addresses (at least one); `cc`/`bcc` optional arrays; `subject`; `body`; `isHtml` true for an HTML body, otherwise plain text; `attachments` an array of file paths inside the account directory, up to 25 MB each. A copy is saved to Sent Items. Success is confirmed on Graph's 202 response.
|
|
70
|
+
|
|
71
|
+
Every address is a **quoted string inside an array**, even when there is only one recipient, and `mailbox` is a plain quoted string. `attachments` takes the same shape: an array of quoted absolute paths.
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mailbox": "rob@example.com",
|
|
76
|
+
"to": ["rob@example.com"],
|
|
77
|
+
"cc": ["tom@example.com"],
|
|
78
|
+
"subject": "Pricing schedule",
|
|
79
|
+
"body": "Schedule attached.",
|
|
80
|
+
"isHtml": false,
|
|
81
|
+
"attachments": ["/path/to/account/uploads/outlook/schedule.pdf"]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`"to": rob@example.com` (unquoted) and `"to": "rob@example.com"` (quoted but not an array) are both rejected before the call reaches Outlook. A rejection here means the arguments were malformed; it is not evidence that sending is broken. Check `outlook-mailbox-info` before reporting a send fault.
|
|
86
|
+
|
|
87
|
+
- `outlook-draft` — create a draft in the Drafts folder (same fields, same array-of-quoted-strings shape as send, including `attachments`). Returns the draft's id, and the files are already on the draft, so it can be sent as-is.
|
|
88
|
+
- `outlook-draft-edit draftId=<id>` — update an existing draft in place (Graph drafts are mutable, so no delete-and-reappend). Pass the draftId plus only the fields to change: `to`, `cc`, `bcc`, `subject`, `body`, `isHtml`, `attachments` (recipients and attachments take the same array-of-quoted-strings shape as send). Attachments are added to the draft, never removed. At least one field or attachment is required; a sent message cannot be edited.
|
|
72
89
|
- `outlook-draft-send draftId=<id>` — send a draft created earlier. Graph consumes the draft on send.
|
|
73
90
|
|
|
74
91
|
If the mailbox was registered before send was available, the first send returns "re-register to grant send" — run `outlook-account-register` again and consent to the send scopes.
|
|
@@ -17,6 +17,18 @@ tools:
|
|
|
17
17
|
- name: storage-r2-bucket-create
|
|
18
18
|
publicAllowlist: false
|
|
19
19
|
adminAllowlist: true
|
|
20
|
+
- name: storage-r2-object-list
|
|
21
|
+
publicAllowlist: false
|
|
22
|
+
adminAllowlist: true
|
|
23
|
+
- name: storage-r2-object-get
|
|
24
|
+
publicAllowlist: false
|
|
25
|
+
adminAllowlist: true
|
|
26
|
+
- name: storage-r2-object-put
|
|
27
|
+
publicAllowlist: false
|
|
28
|
+
adminAllowlist: true
|
|
29
|
+
- name: storage-r2-object-delete
|
|
30
|
+
publicAllowlist: false
|
|
31
|
+
adminAllowlist: true
|
|
20
32
|
metadata: {"platform":{"embed":["admin"]}}
|
|
21
33
|
mcp:
|
|
22
34
|
command: node
|
|
@@ -35,7 +47,7 @@ mcp-manifest: auto
|
|
|
35
47
|
|
|
36
48
|
# storage-broker
|
|
37
49
|
|
|
38
|
-
The isolation boundary for per-account Cloudflare D1 and R2 on a multi-tenant install. A sub-account agent
|
|
50
|
+
The isolation boundary for per-account Cloudflare D1 and R2 on a multi-tenant install. A sub-account agent is issued no Cloudflare credential: its `cloudflare.env` carries no account-wide token, and `cf-token.sh` refuses to mint one for a non-house caller. The broker is therefore the only *sanctioned* route to D1 and R2, and it denies cross-account access before calling Cloudflare. It is not an OS-enforced boundary: all accounts share one unix user, so an agent holding `Bash` can read the house credential and reach `wrangler` outside the broker. Closing that requires OS user separation, which was costed and declined as disproportionate — so this stays a sanctioned-path boundary permanently, and nothing tracks closing it. Every D1 and R2 operation goes through these tools, which forward the caller's account as the platform-stamped `x-maxy-caller-account` header (never a tool argument) to the house-level broker service. The service holds the sole account-wide credential in `config/cloudflare-house.env`, resolves each resource's recorded owner, and denies any cross-account access before it ever calls Cloudflare.
|
|
39
51
|
|
|
40
52
|
## Tools
|
|
41
53
|
|
|
@@ -44,9 +56,36 @@ The isolation boundary for per-account Cloudflare D1 and R2 on a multi-tenant in
|
|
|
44
56
|
- `storage-d1-query` — run SQL against a database the caller owns.
|
|
45
57
|
- `storage-r2-bucket-list` — the caller account's R2 buckets.
|
|
46
58
|
- `storage-r2-bucket-create` — create an R2 bucket, registered to the caller.
|
|
59
|
+
- `storage-r2-object-list` — the objects in a bucket the caller owns.
|
|
60
|
+
- `storage-r2-object-get` — download an object to a file in the caller's account directory. Objects over 100 MiB are rejected.
|
|
61
|
+
- `storage-r2-object-put` — upload a file from the caller's account directory. Files over 100 MiB are rejected.
|
|
62
|
+
- `storage-r2-object-delete` — delete an object from a bucket the caller owns.
|
|
47
63
|
|
|
48
64
|
A request against a database or bucket the caller does not own returns an authorization error, not another account's data.
|
|
49
65
|
|
|
66
|
+
## Where the object tools do their file I/O, and why
|
|
67
|
+
|
|
68
|
+
`storage-r2-object-get` writes to a file and `storage-r2-object-put` reads from one, rather than passing bytes through the tool result, because these objects are photos and scans and base64 in a result would push megabytes into the agent's context window.
|
|
69
|
+
|
|
70
|
+
That file I/O happens in this MCP process, under the caller's own account, and never in the house-level broker service. The service holds the account-wide credential, so a caller-supplied path resolved there would be a house-privileged arbitrary read and write reachable from any sub-account — a `put` sourced from `config/cloudflare-house.env` would copy the house master into the caller's own bucket, to be read back legitimately afterwards. Keeping the service free of any filesystem access removes that class entirely, and it needs no `Bash`, so OS user separation would not have closed it.
|
|
71
|
+
|
|
72
|
+
The paths are confined to the caller's account directory by realpath, which a symlink cannot escape. That is a correctness guardrail rather than a boundary: all accounts share one unix user, so an agent holding `Bash` can already read whatever this process can.
|
|
73
|
+
|
|
74
|
+
## The 100 MiB object cap
|
|
75
|
+
|
|
76
|
+
`storage-r2-object-get` and `storage-r2-object-put` refuse any object over 100 MiB. The number lives once, in `platform/lib/storage-broker/src/object-limits.ts`, and both the tools and the broker routes read it from there.
|
|
77
|
+
|
|
78
|
+
It is set independently of the email plugin's 25 MiB attachment cap. Both bound the same class of payload, but for different reasons, and so they move for different reasons: email's tracks an SMTP-side rejection threshold, this one tracks memory in the house process. R2 does not bind either — a single-part upload may be up to 5 GiB — so this cap is the only bound on either transfer.
|
|
79
|
+
|
|
80
|
+
Both directions are checked before anything is buffered, in two places that are not redundant:
|
|
81
|
+
|
|
82
|
+
- `put` is size-checked in this MCP process with `statSync` before the file is read. That is what produces the error naming the file and the limit.
|
|
83
|
+
- The house routes enforce independently, because they are plain loopback HTTP and every agent holds `Bash`: a cap enforced only here is bypassed by one `curl`, and the house process is the one holding the account-wide credential. `/r2/object/put` rejects an over-cap body at Hono's `bodyLimit`, before the body is buffered. `get` is sized from `r2ObjectList`'s recorded `size` before any bytes move.
|
|
84
|
+
|
|
85
|
+
A `get` whose key has no recorded size in the listing is refused, not attempted. No recorded size means no bound, and a stale or lagging listing would otherwise transfer an unbounded object. The cost is that a genuinely missing object reports "no recorded size" rather than Cloudflare's 404.
|
|
86
|
+
|
|
87
|
+
The cap bounds the spike; it does not make it small. At 100 MiB a `get` still leaves the house process holding the object and its base64 form, roughly 2.4x. Streaming and multipart transfer are the real fix for large objects and are deliberately not implemented; the cap stands in for them.
|
|
88
|
+
|
|
50
89
|
## Wrangler resolution
|
|
51
90
|
|
|
52
|
-
The broker service shells wrangler through `npx wrangler`, never bare `wrangler`, for every D1 operation and R2 bucket creation. The brand server PATH carries no global wrangler install; only `npx wrangler` resolves, so a bare `wrangler` call ENOENTs those operations on a real install. R2 bucket listing
|
|
91
|
+
The broker service shells wrangler through `npx wrangler`, never bare `wrangler`, for every D1 operation and R2 bucket creation. The brand server PATH carries no global wrangler install; only `npx wrangler` resolves, so a bare `wrangler` call ENOENTs those operations on a real install. R2 bucket listing and every R2 object operation are exempt: they read the Cloudflare REST API directly, not wrangler.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confine-path.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/confine-path.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, realpathSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { confineToAccountDir } from "../confine-path.js";
|
|
6
|
+
let root;
|
|
7
|
+
let accountDir;
|
|
8
|
+
let outside;
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
// realpath the tmp root: on macOS /var is a symlink to /private/var, so an
|
|
11
|
+
// un-resolved base would never prefix-match the resolved candidates.
|
|
12
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "confine-")));
|
|
13
|
+
accountDir = join(root, "accounts", "acc-a");
|
|
14
|
+
outside = join(root, "elsewhere");
|
|
15
|
+
mkdirSync(accountDir, { recursive: true });
|
|
16
|
+
mkdirSync(outside, { recursive: true });
|
|
17
|
+
writeFileSync(join(accountDir, "ok.txt"), "hi");
|
|
18
|
+
writeFileSync(join(outside, "secret.env"), "TOKEN=x");
|
|
19
|
+
mkdirSync(join(accountDir, "sub"), { recursive: true });
|
|
20
|
+
// The escape a plain string prefix check would miss.
|
|
21
|
+
symlinkSync(join(outside, "secret.env"), join(accountDir, "escape.txt"));
|
|
22
|
+
// A symlinked *directory* inside the account dir, pointing out.
|
|
23
|
+
symlinkSync(outside, join(accountDir, "outlink"));
|
|
24
|
+
process.env.ACCOUNT_DIR = accountDir;
|
|
25
|
+
});
|
|
26
|
+
afterAll(() => {
|
|
27
|
+
delete process.env.ACCOUNT_DIR;
|
|
28
|
+
rmSync(root, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
describe("confineToAccountDir", () => {
|
|
31
|
+
it("accepts a regular file inside the account dir", () => {
|
|
32
|
+
expect(confineToAccountDir(join(accountDir, "ok.txt"), true)).toBe(join(accountDir, "ok.txt"));
|
|
33
|
+
});
|
|
34
|
+
it("rejects a .. traversal out of the account dir", () => {
|
|
35
|
+
expect(() => confineToAccountDir(join(accountDir, "..", "..", "elsewhere", "secret.env"), true)).toThrow(/outside the account directory/);
|
|
36
|
+
});
|
|
37
|
+
it("rejects a symlink resolving outside the account dir", () => {
|
|
38
|
+
expect(() => confineToAccountDir(join(accountDir, "escape.txt"), true)).toThrow(/outside the account directory/);
|
|
39
|
+
});
|
|
40
|
+
it("rejects a directory passed where a file is required", () => {
|
|
41
|
+
expect(() => confineToAccountDir(join(accountDir, "sub"), true)).toThrow(/not a regular file/);
|
|
42
|
+
});
|
|
43
|
+
it("reports a missing source file as not found, not as an escape", () => {
|
|
44
|
+
expect(() => confineToAccountDir(join(accountDir, "nope.txt"), true)).toThrow(/File not found/);
|
|
45
|
+
});
|
|
46
|
+
it("rejects a not-yet-existing destination whose parent is outside", () => {
|
|
47
|
+
expect(() => confineToAccountDir(join(outside, "new.bin"), false)).toThrow(/outside the account directory/);
|
|
48
|
+
});
|
|
49
|
+
// The destination branch cannot realpath a path that does not exist, so it
|
|
50
|
+
// resolves the parent. When the final component *does* already exist and is a
|
|
51
|
+
// symlink out, resolving only the parent would pass it and the caller's write
|
|
52
|
+
// would follow the link out of the account directory — the same escape the
|
|
53
|
+
// source branch rejects.
|
|
54
|
+
it("rejects a destination whose existing final component is a symlink out", () => {
|
|
55
|
+
expect(() => confineToAccountDir(join(accountDir, "escape.txt"), false)).toThrow(/outside the account directory/);
|
|
56
|
+
});
|
|
57
|
+
it("rejects a destination reached through a symlinked parent directory", () => {
|
|
58
|
+
expect(() => confineToAccountDir(join(accountDir, "outlink", "new.bin"), false)).toThrow(/outside the account directory/);
|
|
59
|
+
});
|
|
60
|
+
it("accepts a not-yet-existing destination inside the account dir", () => {
|
|
61
|
+
expect(confineToAccountDir(join(accountDir, "new.bin"), false)).toBe(join(accountDir, "new.bin"));
|
|
62
|
+
});
|
|
63
|
+
it("accepts a not-yet-existing destination in a subdirectory", () => {
|
|
64
|
+
expect(confineToAccountDir(join(accountDir, "sub", "new.bin"), false)).toBe(join(accountDir, "sub", "new.bin"));
|
|
65
|
+
});
|
|
66
|
+
it("throws when no account context is resolvable, rather than defaulting open", () => {
|
|
67
|
+
const saved = process.env.ACCOUNT_DIR;
|
|
68
|
+
const savedRoot = process.env.PLATFORM_ROOT;
|
|
69
|
+
const savedId = process.env.ACCOUNT_ID;
|
|
70
|
+
delete process.env.ACCOUNT_DIR;
|
|
71
|
+
delete process.env.PLATFORM_ROOT;
|
|
72
|
+
delete process.env.ACCOUNT_ID;
|
|
73
|
+
try {
|
|
74
|
+
expect(() => confineToAccountDir(join(accountDir, "ok.txt"), true)).toThrow(/No account context/);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
process.env.ACCOUNT_DIR = saved;
|
|
78
|
+
if (savedRoot !== undefined)
|
|
79
|
+
process.env.PLATFORM_ROOT = savedRoot;
|
|
80
|
+
if (savedId !== undefined)
|
|
81
|
+
process.env.ACCOUNT_ID = savedId;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
//# sourceMappingURL=confine-path.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confine-path.test.js","sourceRoot":"","sources":["../../src/__tests__/confine-path.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"put-source.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/put-source.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync, truncateSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { R2_OBJECT_MAX_BYTES } from "../../../../../lib/storage-broker/dist/object-limits.js";
|
|
6
|
+
// Task 1695 — the property under test is an ordering: the cap must fire BEFORE
|
|
7
|
+
// the file is read. Spying on readFileSync is what makes that observable. An
|
|
8
|
+
// assertion that only checked the error message would pass just as happily on an
|
|
9
|
+
// implementation that slurps a 3 GB file into memory and then measures its
|
|
10
|
+
// length, which is the bug this exists to prevent.
|
|
11
|
+
//
|
|
12
|
+
// node:fs is mocked partially so confine-path's realpathSync/statSync stay real
|
|
13
|
+
// and the confinement predicate is still exercised for what it is.
|
|
14
|
+
const readFileSpy = vi.fn();
|
|
15
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
readFileSync: (...args) => readFileSpy(...args),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
const { readPutSource } = await import("../put-source.js");
|
|
23
|
+
let dir;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
// realpath'd at creation: on macOS mkdtemp hands back /var/folders/… which
|
|
26
|
+
// canonicalises to /private/var/folders/…, and confineToAccountDir realpaths
|
|
27
|
+
// both sides. Without this the paths under test differ from the paths asserted
|
|
28
|
+
// for a reason that has nothing to do with the cap.
|
|
29
|
+
dir = realpathSync(mkdtempSync(join(tmpdir(), "put-source-")));
|
|
30
|
+
process.env.ACCOUNT_DIR = dir;
|
|
31
|
+
readFileSpy.mockReset();
|
|
32
|
+
readFileSpy.mockReturnValue(Buffer.from([1, 2, 3]));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
delete process.env.ACCOUNT_DIR;
|
|
37
|
+
});
|
|
38
|
+
/** A sparse file of `size` bytes: statSync reports the size, no disk is used and
|
|
39
|
+
* nothing is ever written. A real 100 MiB fixture would make this suite the
|
|
40
|
+
* thing that thrashes the machine. */
|
|
41
|
+
function sparseFile(name, size) {
|
|
42
|
+
const p = join(dir, name);
|
|
43
|
+
writeFileSync(p, "");
|
|
44
|
+
truncateSync(p, size);
|
|
45
|
+
return p;
|
|
46
|
+
}
|
|
47
|
+
describe("readPutSource", () => {
|
|
48
|
+
it("rejects an over-cap file before reading it", () => {
|
|
49
|
+
const p = sparseFile("huge.bin", R2_OBJECT_MAX_BYTES + 1);
|
|
50
|
+
expect(() => readPutSource(p)).toThrow(/huge\.bin/);
|
|
51
|
+
// The assertion that matters. Without it this test would pass on an
|
|
52
|
+
// implementation that reads the whole file and then checks its length.
|
|
53
|
+
expect(readFileSpy).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
it("names the file and the limit when it rejects", () => {
|
|
56
|
+
const p = sparseFile("huge.bin", 150 * 1024 * 1024);
|
|
57
|
+
expect(() => readPutSource(p)).toThrow(/huge\.bin is 150\.0 MiB.*100\.0 MiB/);
|
|
58
|
+
});
|
|
59
|
+
it("reads a file at exactly the cap — the cap is a bound, not a wall", () => {
|
|
60
|
+
const p = sparseFile("atcap.bin", R2_OBJECT_MAX_BYTES);
|
|
61
|
+
const out = readPutSource(p);
|
|
62
|
+
expect(readFileSpy).toHaveBeenCalledWith(p);
|
|
63
|
+
expect(out.path).toBe(p);
|
|
64
|
+
});
|
|
65
|
+
it("reads an ordinary file", () => {
|
|
66
|
+
const p = join(dir, "small.bin");
|
|
67
|
+
writeFileSync(p, Buffer.from([1, 2, 3]));
|
|
68
|
+
const out = readPutSource(p);
|
|
69
|
+
expect(out.bytes).toEqual(Buffer.from([1, 2, 3]));
|
|
70
|
+
expect(readFileSpy).toHaveBeenCalledWith(p);
|
|
71
|
+
});
|
|
72
|
+
it("still rejects a path outside the account directory, before any size check", () => {
|
|
73
|
+
expect(() => readPutSource("/etc/hosts")).toThrow(/outside the account directory/);
|
|
74
|
+
expect(readFileSpy).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
//# sourceMappingURL=put-source.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"put-source.test.js","sourceRoot":"","sources":["../../src/__tests__/put-source.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `p` and require it inside the caller's account directory.
|
|
3
|
+
*
|
|
4
|
+
* `mustExist` true (a source to read): the path itself is realpathed and must
|
|
5
|
+
* be a regular file. False (a destination to write): the path does not exist
|
|
6
|
+
* yet and cannot be realpathed, so its *parent* is — which is exactly what a
|
|
7
|
+
* symlinked parent directory would otherwise subvert.
|
|
8
|
+
*
|
|
9
|
+
* Returns the resolved absolute path. Throws naming the offending path.
|
|
10
|
+
*/
|
|
11
|
+
export declare function confineToAccountDir(p: string, mustExist: boolean): string;
|
|
12
|
+
//# sourceMappingURL=confine-path.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confine-path.d.ts","sourceRoot":"","sources":["../src/confine-path.ts"],"names":[],"mappings":"AAyCA;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CA4DzE"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confine an agent-supplied file path to the caller's own account directory.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the predicate proven in the email plugin's resolveOutboundAttachments
|
|
5
|
+
* (platform/plugins/email/mcp/src/lib/attachment-resolve.ts): realpath the
|
|
6
|
+
* candidate and require the result under the realpathed account directory, so a
|
|
7
|
+
* symlink cannot escape a plain string prefix check. Copied rather than
|
|
8
|
+
* imported because platform/lib/* is the only cross-plugin-importable surface
|
|
9
|
+
* and this predicate does not live there; consolidating the duplicates is its
|
|
10
|
+
* own concern, not this tool's.
|
|
11
|
+
*
|
|
12
|
+
* This is a correctness guardrail, not a security boundary, and the difference
|
|
13
|
+
* matters. Every account shares one unix user and the agent holds Bash, so it
|
|
14
|
+
* can already read anything this process can (Task 1690). What actually
|
|
15
|
+
* contains the blast radius is that the house-level broker route touches no
|
|
16
|
+
* filesystem at all: file I/O stays here, in a process running at the agent's
|
|
17
|
+
* own privilege, so it grants nothing the agent did not already have. Moving
|
|
18
|
+
* this check server-side would not harden it — it would create the very
|
|
19
|
+
* house-privileged arbitrary read/write it appears to prevent.
|
|
20
|
+
*/
|
|
21
|
+
import { realpathSync, statSync } from "node:fs";
|
|
22
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
23
|
+
/** The caller's account directory. ACCOUNT_DIR is the canonical spawn-set var;
|
|
24
|
+
* fall back to PLATFORM_ROOT + ACCOUNT_ID exactly as the email and url-get
|
|
25
|
+
* tools do. Throws rather than returning a default: with no account context
|
|
26
|
+
* there is nothing to validate against, and failing closed is the only safe
|
|
27
|
+
* answer. */
|
|
28
|
+
function accountDir() {
|
|
29
|
+
const direct = process.env.ACCOUNT_DIR;
|
|
30
|
+
if (direct && direct.trim())
|
|
31
|
+
return direct;
|
|
32
|
+
const platformRoot = process.env.PLATFORM_ROOT;
|
|
33
|
+
const accountId = process.env.ACCOUNT_ID;
|
|
34
|
+
if (platformRoot && accountId && !accountId.includes("/") && !accountId.includes("..")) {
|
|
35
|
+
return join(platformRoot, "..", "data", "accounts", accountId);
|
|
36
|
+
}
|
|
37
|
+
throw new Error("No account context (ACCOUNT_DIR unset and PLATFORM_ROOT+ACCOUNT_ID unavailable) to validate paths against.");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve `p` and require it inside the caller's account directory.
|
|
41
|
+
*
|
|
42
|
+
* `mustExist` true (a source to read): the path itself is realpathed and must
|
|
43
|
+
* be a regular file. False (a destination to write): the path does not exist
|
|
44
|
+
* yet and cannot be realpathed, so its *parent* is — which is exactly what a
|
|
45
|
+
* symlinked parent directory would otherwise subvert.
|
|
46
|
+
*
|
|
47
|
+
* Returns the resolved absolute path. Throws naming the offending path.
|
|
48
|
+
*/
|
|
49
|
+
export function confineToAccountDir(p, mustExist) {
|
|
50
|
+
let base;
|
|
51
|
+
try {
|
|
52
|
+
base = realpathSync(accountDir());
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new Error(`Account directory is not accessible: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
let resolved;
|
|
58
|
+
if (mustExist) {
|
|
59
|
+
try {
|
|
60
|
+
resolved = realpathSync(p);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err.code === "ENOENT") {
|
|
64
|
+
throw new Error(`File not found: ${p}`);
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Cannot read ${p}: ${err instanceof Error ? err.message : String(err)}`);
|
|
67
|
+
}
|
|
68
|
+
if (!statSync(resolved).isFile()) {
|
|
69
|
+
throw new Error(`${p} is not a regular file.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const absolute = resolve(p);
|
|
74
|
+
// A destination that already exists must be realpathed like any source: if
|
|
75
|
+
// the final component is a symlink pointing out of the account directory, a
|
|
76
|
+
// write to it follows the link out, and resolving only the parent would let
|
|
77
|
+
// that through. The source branch rejects exactly this escape, so the two
|
|
78
|
+
// branches would otherwise disagree about what "confined" means.
|
|
79
|
+
try {
|
|
80
|
+
resolved = realpathSync(absolute);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
if (err.code !== "ENOENT") {
|
|
84
|
+
throw new Error(`Cannot resolve destination ${p}: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
+
}
|
|
86
|
+
// It does not exist yet, so it cannot be realpathed. Resolve its parent —
|
|
87
|
+
// which is what a symlinked parent directory would otherwise subvert —
|
|
88
|
+
// and re-attach the final name.
|
|
89
|
+
let parent;
|
|
90
|
+
try {
|
|
91
|
+
parent = realpathSync(dirname(absolute));
|
|
92
|
+
}
|
|
93
|
+
catch (perr) {
|
|
94
|
+
if (perr.code === "ENOENT") {
|
|
95
|
+
throw new Error(`Destination directory does not exist: ${dirname(absolute)}`);
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Cannot resolve destination ${p}: ${perr instanceof Error ? perr.message : String(perr)}`);
|
|
98
|
+
}
|
|
99
|
+
resolved = join(parent, basename(absolute));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (resolved !== base && !resolved.startsWith(base + "/")) {
|
|
103
|
+
throw new Error(`${p} is outside the account directory and cannot be used.`);
|
|
104
|
+
}
|
|
105
|
+
return resolved;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=confine-path.js.map
|