@open-everywhere/provider-notion 0.1.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.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +48 -0
- package/agent/channels/notion-acting-identity.ts +88 -0
- package/agent/channels/notion-ancestry-trust.ts +458 -0
- package/agent/channels/notion-api.ts +1290 -0
- package/agent/channels/notion-bot-user.ts +250 -0
- package/agent/channels/notion-catch-up.ts +339 -0
- package/agent/channels/notion-credentials.ts +417 -0
- package/agent/channels/notion-engagement.ts +372 -0
- package/agent/channels/notion-event-normalizer.ts +587 -0
- package/agent/channels/notion-identity.ts +271 -0
- package/agent/channels/notion-ingress-drain.ts +289 -0
- package/agent/channels/notion-ingress.ts +182 -0
- package/agent/channels/notion-message-ops.ts +140 -0
- package/agent/channels/notion-origin-codec.ts +38 -0
- package/agent/channels/notion-post-arm.ts +189 -0
- package/agent/channels/notion-question-return.ts +223 -0
- package/agent/channels/notion-read.ts +283 -0
- package/agent/channels/notion-scope.ts +560 -0
- package/agent/channels/notion-stop.ts +169 -0
- package/agent/channels/notion-supervisor.ts +856 -0
- package/agent/channels/notion-webhook-timing.ts +131 -0
- package/agent/channels/notion-webhook-verifier.ts +55 -0
- package/agent/channels/notion-webhook.ts +963 -0
- package/agent/channels/notion-work-search.ts +74 -0
- package/agent/channels/notion-work.ts +144 -0
- package/agent/channels/notion.ts +1242 -0
- package/agent/contributions.ts +39 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/memory/blocks.ts +295 -0
- package/agent/memory/memory.ts +622 -0
- package/agent/provider-events.ts +198 -0
- package/agent/register.ts +144 -0
- package/agent/schedules/notion-ingress.ts +20 -0
- package/agent/surface-parity.ts +105 -0
- package/agent/tools/notion_search.ts +110 -0
- package/agent/tools/read_notion_comments.ts +250 -0
- package/agent/tools/read_notion_page.ts +351 -0
- package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
- package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-acting-identity.js +85 -0
- package/dist/agent/channels/notion-acting-identity.js.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.js +331 -0
- package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
- package/dist/agent/channels/notion-api.d.ts +655 -0
- package/dist/agent/channels/notion-api.d.ts.map +1 -0
- package/dist/agent/channels/notion-api.js +938 -0
- package/dist/agent/channels/notion-api.js.map +1 -0
- package/dist/agent/channels/notion-bot-user.d.ts +107 -0
- package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
- package/dist/agent/channels/notion-bot-user.js +192 -0
- package/dist/agent/channels/notion-bot-user.js.map +1 -0
- package/dist/agent/channels/notion-catch-up.d.ts +64 -0
- package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
- package/dist/agent/channels/notion-catch-up.js +289 -0
- package/dist/agent/channels/notion-catch-up.js.map +1 -0
- package/dist/agent/channels/notion-credentials.d.ts +210 -0
- package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
- package/dist/agent/channels/notion-credentials.js +360 -0
- package/dist/agent/channels/notion-credentials.js.map +1 -0
- package/dist/agent/channels/notion-engagement.d.ts +219 -0
- package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
- package/dist/agent/channels/notion-engagement.js +283 -0
- package/dist/agent/channels/notion-engagement.js.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.js +542 -0
- package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
- package/dist/agent/channels/notion-identity.d.ts +119 -0
- package/dist/agent/channels/notion-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-identity.js +205 -0
- package/dist/agent/channels/notion-identity.js.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.js +185 -0
- package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
- package/dist/agent/channels/notion-ingress.d.ts +64 -0
- package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress.js +130 -0
- package/dist/agent/channels/notion-ingress.js.map +1 -0
- package/dist/agent/channels/notion-message-ops.d.ts +3 -0
- package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/notion-message-ops.js +126 -0
- package/dist/agent/channels/notion-message-ops.js.map +1 -0
- package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
- package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/notion-origin-codec.js +32 -0
- package/dist/agent/channels/notion-origin-codec.js.map +1 -0
- package/dist/agent/channels/notion-post-arm.d.ts +3 -0
- package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/notion-post-arm.js +185 -0
- package/dist/agent/channels/notion-post-arm.js.map +1 -0
- package/dist/agent/channels/notion-question-return.d.ts +77 -0
- package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
- package/dist/agent/channels/notion-question-return.js +183 -0
- package/dist/agent/channels/notion-question-return.js.map +1 -0
- package/dist/agent/channels/notion-read.d.ts +62 -0
- package/dist/agent/channels/notion-read.d.ts.map +1 -0
- package/dist/agent/channels/notion-read.js +257 -0
- package/dist/agent/channels/notion-read.js.map +1 -0
- package/dist/agent/channels/notion-scope.d.ts +188 -0
- package/dist/agent/channels/notion-scope.d.ts.map +1 -0
- package/dist/agent/channels/notion-scope.js +433 -0
- package/dist/agent/channels/notion-scope.js.map +1 -0
- package/dist/agent/channels/notion-stop.d.ts +48 -0
- package/dist/agent/channels/notion-stop.d.ts.map +1 -0
- package/dist/agent/channels/notion-stop.js +141 -0
- package/dist/agent/channels/notion-stop.js.map +1 -0
- package/dist/agent/channels/notion-supervisor.d.ts +462 -0
- package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
- package/dist/agent/channels/notion-supervisor.js +482 -0
- package/dist/agent/channels/notion-supervisor.js.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.js +99 -0
- package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.js +53 -0
- package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/notion-webhook.d.ts +242 -0
- package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook.js +675 -0
- package/dist/agent/channels/notion-webhook.js.map +1 -0
- package/dist/agent/channels/notion-work-search.d.ts +9 -0
- package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
- package/dist/agent/channels/notion-work-search.js +69 -0
- package/dist/agent/channels/notion-work-search.js.map +1 -0
- package/dist/agent/channels/notion-work.d.ts +68 -0
- package/dist/agent/channels/notion-work.d.ts.map +1 -0
- package/dist/agent/channels/notion-work.js +126 -0
- package/dist/agent/channels/notion-work.js.map +1 -0
- package/dist/agent/channels/notion.d.ts +95 -0
- package/dist/agent/channels/notion.d.ts.map +1 -0
- package/dist/agent/channels/notion.js +1081 -0
- package/dist/agent/channels/notion.js.map +1 -0
- package/dist/agent/contributions.d.ts +32 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +37 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/memory/blocks.d.ts +58 -0
- package/dist/agent/memory/blocks.d.ts.map +1 -0
- package/dist/agent/memory/blocks.js +264 -0
- package/dist/agent/memory/blocks.js.map +1 -0
- package/dist/agent/memory/memory.d.ts +157 -0
- package/dist/agent/memory/memory.d.ts.map +1 -0
- package/dist/agent/memory/memory.js +471 -0
- package/dist/agent/memory/memory.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +196 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +128 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/schedules/notion-ingress.d.ts +6 -0
- package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
- package/dist/agent/schedules/notion-ingress.js +16 -0
- package/dist/agent/schedules/notion-ingress.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +99 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/dist/agent/tools/notion_search.d.ts +56 -0
- package/dist/agent/tools/notion_search.d.ts.map +1 -0
- package/dist/agent/tools/notion_search.js +100 -0
- package/dist/agent/tools/notion_search.js.map +1 -0
- package/dist/agent/tools/read_notion_comments.d.ts +57 -0
- package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_comments.js +236 -0
- package/dist/agent/tools/read_notion_comments.js.map +1 -0
- package/dist/agent/tools/read_notion_page.d.ts +53 -0
- package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_page.js +327 -0
- package/dist/agent/tools/read_notion_page.js.map +1 -0
- package/package.json +231 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// The comments/inline-discussion read tool for the ambient Notion surface โ the SIBLING of
|
|
2
|
+
// read_notion_page (which returns the page BODY). The comment thread you were WOKEN on is already in
|
|
3
|
+
// context; this pulls the OTHER discussions on the page (a second commenter on another block, an
|
|
4
|
+
// older resolved argument the reply needs to reconcile) BY REFERENCE โ fetched on demand, never
|
|
5
|
+
// inlined into the wake, so a heavily-annotated page can't blow the backscroll budget.
|
|
6
|
+
//
|
|
7
|
+
// SURFACE GATE + REPLAY DOCTRINE + UNTRUSTED WRAP: identical to read_notion_page.ts โ a per-session
|
|
8
|
+
// dynamic resolver gated on `notion โ sources` (workerChatArmMount) with a per-turn origin fence
|
|
9
|
+
// (assertTurnSurface), a top-level factored defineTool (never an inline literal โ the bundler's
|
|
10
|
+
// build-time transform hoists one under a build-order-counter name that shifts across deploys; eve
|
|
11
|
+
// derives a session-scoped step id for a factored, unstamped entry and self-heals it on cold resume,
|
|
12
|
+
// OPE-867), and NO `approval:` (read-only).
|
|
13
|
+
//
|
|
14
|
+
// DOMAIN FENCE (channels/notion-scope.ts, OPE-236): BOTH caller-supplied ids are gated โ `page`, and
|
|
15
|
+
// `block`, which addresses a block directly and never touches `page` at all. Fail closed on either,
|
|
16
|
+
// and audit the refusal; the one deployment-wide credential fences the workspace, never the domain.
|
|
17
|
+
//
|
|
18
|
+
// Comment bodies are EXTERNAL, UNTRUSTED content (commenters include workspace guests / externals),
|
|
19
|
+
// so the whole rendered listing is wrapped through ingestUntrusted (toModelOutput) โ the model must
|
|
20
|
+
// treat comment text as DATA, not instructions. Author ids are surfaced as DATA too, never as a
|
|
21
|
+
// trust signal.
|
|
22
|
+
//
|
|
23
|
+
// ๐ด THIS TOOL IS THE DELEGATED WORKER'S ONLY (OPE-1014). It has ONE mount arm โ the chat arm โ and
|
|
24
|
+
// that arm now mounts on the `task` grain alone (access/source-scoped-mount.ts `workerChatArmMount`).
|
|
25
|
+
// A conversational Notion seat reads the same comment layer through `read_channel` (channels/
|
|
26
|
+
// notion-read.ts), which sweeps every discussion on the page headed by its anchor block, a SUPERSET
|
|
27
|
+
// of what this tool returns, and `read_thread`, which reads the one discussion the turn is in. Since
|
|
28
|
+
// OPE-1039 the generic pair carries the OPE-1026 dated-read framing too โ the `readAt` stamp, the
|
|
29
|
+
// per-line age and the HISTORY marker that stops the model reading a stale comment's claim as the
|
|
30
|
+
// page's present state โ so the seat loses no safety framing by taking the generic route.
|
|
31
|
+
//
|
|
32
|
+
// The worker kept it because it had no substitute: access/worker-seat.ts withholds `read_channel` /
|
|
33
|
+
// `read_thread` from every delegated worker. That is no longer the whole story (OPE-1064): `WORK_ROLES`
|
|
34
|
+
// now gives Notion a CONTAINER row too, so a work-granted domain's delegated worker also holds the
|
|
35
|
+
// generic `read_work_channel` / `read_work_thread` pair, which the paragraph above already says
|
|
36
|
+
// SUPERSEDES this tool's own read (a superset via `read_channel`'s discussion sweep). This tool has
|
|
37
|
+
// no work arm of its own to fall back on, so the `task` grain is still its whole mount โ it stays
|
|
38
|
+
// live for now because retiring a shipped tool is its own reviewed change (filed on OPE-1064), not
|
|
39
|
+
// a side effect of adding the neutral path beside it.
|
|
40
|
+
|
|
41
|
+
import { defineDynamic, defineTool } from "eve/tools";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
import { readNotionComments, resolveBlockOwningPage, resolveNotionCredential, type NotionCommentSummary } from "../channels/notion-api.js";
|
|
44
|
+
import { notionCallerCredential } from "../channels/notion-credentials.js";
|
|
45
|
+
import { assertNotionActingIdentity } from "../channels/notion-acting-identity.js";
|
|
46
|
+
import { assertNotionBlockAllowed, assertNotionPageAllowed } from "../channels/notion-scope.js";
|
|
47
|
+
import {
|
|
48
|
+
ingestUntrustedModelOutput,
|
|
49
|
+
assertTurnSurface,
|
|
50
|
+
TurnOriginFenceError,
|
|
51
|
+
auditOriginFenceRefusal,
|
|
52
|
+
bindFrom,
|
|
53
|
+
workerChatArmMount,
|
|
54
|
+
getConfig,
|
|
55
|
+
preRegisterDynamicEntry,
|
|
56
|
+
resolvePartition,
|
|
57
|
+
declaredWorkspaceTrust,
|
|
58
|
+
ageAtRead,
|
|
59
|
+
type SurfaceTrust,
|
|
60
|
+
type BindableContext,
|
|
61
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
62
|
+
|
|
63
|
+
const MAX_COMMENTS = 100;
|
|
64
|
+
const MAX_BLOCK_SCANS = 80;
|
|
65
|
+
|
|
66
|
+
/** Render the flattened comments into a human-readable, per-discussion listing. The WHOLE string is
|
|
67
|
+
* wrapped untrusted by toModelOutput, so bodies + author ids inside it are DATA.
|
|
68
|
+
*
|
|
69
|
+
* OPE-1026 โ each line carries its age at the moment of the read (see {@link ageAtRead}), the same
|
|
70
|
+
* fact `read_linear_issue.ts` renders for Linear comments: the discriminator between "an old comment
|
|
71
|
+
* said X" and "X is true now". `readAt` is optional so a stored output from before this field
|
|
72
|
+
* existed still renders โ with no invented age. */
|
|
73
|
+
function renderComments(comments: readonly NotionCommentSummary[], readAt?: string): string {
|
|
74
|
+
if (comments.length === 0) return "";
|
|
75
|
+
// Group by anchor (block/page) โ each anchor is a distinct inline discussion.
|
|
76
|
+
const byAnchor = new Map<string, NotionCommentSummary[]>();
|
|
77
|
+
for (const c of comments) {
|
|
78
|
+
const key = c.anchorId ?? "(unknown)";
|
|
79
|
+
let list = byAnchor.get(key);
|
|
80
|
+
if (!list) {
|
|
81
|
+
list = [];
|
|
82
|
+
byAnchor.set(key, list);
|
|
83
|
+
}
|
|
84
|
+
list.push(c);
|
|
85
|
+
}
|
|
86
|
+
const sections: string[] = [];
|
|
87
|
+
for (const [anchor, list] of byAnchor) {
|
|
88
|
+
const disc = list.find((c) => c.discussionId)?.discussionId;
|
|
89
|
+
const head = `discussion on block ${anchor}${disc ? ` (discussion_id ${disc})` : ""}:`;
|
|
90
|
+
const lines = list.map((c) => {
|
|
91
|
+
const who = c.authorId ? `${c.authorKind ?? "user"} ${c.authorId}` : "unknown author";
|
|
92
|
+
const when = c.createdTime ? ` at ${c.createdTime}` : "";
|
|
93
|
+
const age = ageAtRead(c.createdTime, readAt);
|
|
94
|
+
const old = age ? ` (${age} before this read)` : "";
|
|
95
|
+
return ` - ${who}${when}${old}: ${c.text}`;
|
|
96
|
+
});
|
|
97
|
+
sections.push([head, ...lines].join("\n"));
|
|
98
|
+
}
|
|
99
|
+
return sections.join("\n\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// FACTORED + PRE-REGISTERED (see read_notion_page.ts / access/window-gated.ts REPLAY DOCTRINE).
|
|
103
|
+
// Exported for the unit test (its toModelOutput = the untrusted-wrap assertion).
|
|
104
|
+
export const readNotionCommentsEntry = defineTool({
|
|
105
|
+
description:
|
|
106
|
+
"Read the comments / inline discussions on a Notion page (this doc, or another page shared with " +
|
|
107
|
+
"the integration). Use it when a reply needs OTHER discussions on the page โ a comment on a " +
|
|
108
|
+
"different block, or an older thread โ beyond the one you were woken on (already in context). " +
|
|
109
|
+
"Pass `block` to read just one block's inline discussion. Bounded; truncates a heavily-commented page.",
|
|
110
|
+
inputSchema: z.object({
|
|
111
|
+
page: z
|
|
112
|
+
.string()
|
|
113
|
+
.min(1)
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("The Notion page id whose comments to read. Defaults to THIS doc โ the page the comment you're on lives in."),
|
|
116
|
+
block: z
|
|
117
|
+
.string()
|
|
118
|
+
.min(1)
|
|
119
|
+
.optional()
|
|
120
|
+
.describe("Read ONLY this block's inline discussion (a block/page id), instead of sweeping the whole page."),
|
|
121
|
+
}),
|
|
122
|
+
outputSchema: z.object({
|
|
123
|
+
page: z.string(),
|
|
124
|
+
discussions: z.number(),
|
|
125
|
+
comments: z.number(),
|
|
126
|
+
blocksScanned: z.number(),
|
|
127
|
+
truncated: z.boolean(),
|
|
128
|
+
text: z.string(),
|
|
129
|
+
// OPE-1026 โ the moment the comments below were fetched, stamped by `execute` and carried to
|
|
130
|
+
// `toModelOutput`. Optional because `toModelOutput` also runs over outputs stored before this
|
|
131
|
+
// field existed; the render drops the read-time line rather than inventing one (see
|
|
132
|
+
// read_linear_issue.ts's `readAt`, the pattern this copies).
|
|
133
|
+
readAt: z.string().optional(),
|
|
134
|
+
// OPE-331 โ the bound workspace's declared tier, carried execute โ toModelOutput. Same shape and
|
|
135
|
+
// same reasoning as read_notion_page's field; model-invisible behind the authored projection.
|
|
136
|
+
workspaceTrust: z.enum(["internal", "public"]).optional(),
|
|
137
|
+
}),
|
|
138
|
+
// NO `approval:` โ read-only; a dynamic-tool approval fn fails a resumed session's replay lookup
|
|
139
|
+
// (same pin read_notion_page / access/window-gated.ts document). Absent approval === never() is correct.
|
|
140
|
+
async execute(input, ctx2) {
|
|
141
|
+
// Per-turn surface fence (finding 1b): a non-Notion-origin turn on a unified window must not read
|
|
142
|
+
// Notion comments even though the tool is armed for the session.
|
|
143
|
+
// OPE-732: the fence's refusal is audited here (see read_notion_page.ts's note).
|
|
144
|
+
try {
|
|
145
|
+
assertTurnSurface(ctx2 as BindableContext, "notion");
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err instanceof TurnOriginFenceError) await auditOriginFenceRefusal(ctx2 as BindableContext, "notion");
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
// Default to THIS session's page (bindFrom(ctx).surface = the page id for Notion).
|
|
151
|
+
const page = input.page ?? bindFrom(ctx2 as BindableContext).surface;
|
|
152
|
+
if (!page) throw new Error("No Notion page id available for this session โ pass `page` explicitly.");
|
|
153
|
+
// DOMAIN FENCE (OPE-236) โ see read_notion_page.ts and channels/notion-scope.ts. The one
|
|
154
|
+
// deployment-wide integration key bounds the WORKSPACE, not the domain, so the domain allowlist
|
|
155
|
+
// is ours to enforce: fail closed on an unresolvable page, audit every refusal.
|
|
156
|
+
const domains = getConfig().loadDomains();
|
|
157
|
+
await assertNotionPageAllowed(ctx2 as BindableContext, page, domains);
|
|
158
|
+
// OPE-467 / OPE-499 โ the carried delivery credential decides, when one is present; the CALLING
|
|
159
|
+
// domain's own binding is the fallback (see read_notion_page.ts / notion-credentials.ts).
|
|
160
|
+
// OPE-505: `cred` is the key AND its endpoint rules; spreading it carries the fence into every
|
|
161
|
+
// REST call below (notion-api.ts funnels them all through one guarded fetch).
|
|
162
|
+
// ๐ด OPE-1525 โ THE OWN-ACCOUNT GATE, on the line below's behalf. `notionCallerCredential`
|
|
163
|
+
// resolves an `install`-family key, so a member who asked to act with THEIR OWN Notion account
|
|
164
|
+
// has no answer here. This tool is the DELEGATED WORKER's own arm โ the grain the live
|
|
165
|
+
// substitution ran in โ so leaving it ungated while `read_notion_page` is gated would move the
|
|
166
|
+
// defect one verb along. The gate refuses that turn and writes the `connector.acting_identity`
|
|
167
|
+
// row either way; see channels/notion-acting-identity.ts.
|
|
168
|
+
await assertNotionActingIdentity(ctx2 as BindableContext, "read_notion_comments");
|
|
169
|
+
const cred = await resolveNotionCredential(notionCallerCredential(ctx2 as BindableContext, domains));
|
|
170
|
+
if (!cred) throw new Error("no value stored under db:notion:channel-key โ cannot read the comments.");
|
|
171
|
+
// `block` NEEDS ITS OWN GATE: the drill-in addresses the block directly and never touches `page`
|
|
172
|
+
// (notion-api.ts readNotionComments returns early on it), so fencing `page` alone would leave the
|
|
173
|
+
// whole fence bypassable in one argument. Costs one `GET /v1/blocks/{id}` only when the block
|
|
174
|
+
// isn't already in scope by id โ the common case (a top-level block of this very page) still
|
|
175
|
+
// pays it, which is the honest price of not trusting a caller-supplied id.
|
|
176
|
+
if (input.block) {
|
|
177
|
+
await assertNotionBlockAllowed(ctx2 as BindableContext, input.block, domains, (id) => resolveBlockOwningPage(id, { ...cred }));
|
|
178
|
+
}
|
|
179
|
+
const res = await readNotionComments(page, {
|
|
180
|
+
...cred,
|
|
181
|
+
block: input.block,
|
|
182
|
+
maxComments: MAX_COMMENTS,
|
|
183
|
+
maxBlockScans: MAX_BLOCK_SCANS,
|
|
184
|
+
});
|
|
185
|
+
// OPE-1026 โ the moment the fetch above returned. Stamped AFTER the fetch, so it never claims a
|
|
186
|
+
// read is fresher than it is (see read_linear_issue.ts's `readAt`).
|
|
187
|
+
const readAt = new Date().toISOString();
|
|
188
|
+
// OPE-331 โ resolve the workspace tier where a ctx exists; fail-soft to absent (the table's
|
|
189
|
+
// pre-existing fail-closed answer).
|
|
190
|
+
let workspaceTrust: SurfaceTrust | undefined;
|
|
191
|
+
try {
|
|
192
|
+
workspaceTrust = declaredWorkspaceTrust(resolvePartition(ctx2 as BindableContext).workspaceKey);
|
|
193
|
+
} catch {
|
|
194
|
+
workspaceTrust = undefined;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
page,
|
|
198
|
+
discussions: res.discussionCount,
|
|
199
|
+
comments: res.comments.length,
|
|
200
|
+
blocksScanned: res.blocksScanned,
|
|
201
|
+
truncated: res.truncated,
|
|
202
|
+
text: renderComments(res.comments, readAt),
|
|
203
|
+
readAt,
|
|
204
|
+
...(workspaceTrust !== undefined ? { workspaceTrust } : {}),
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
toModelOutput(output) {
|
|
208
|
+
const header =
|
|
209
|
+
output.comments === 0
|
|
210
|
+
? `Notion page ${output.page} has no readable comments.`
|
|
211
|
+
: `Notion page ${output.page} โ ${output.comments} comment(s) across ${output.discussions} discussion(s):`;
|
|
212
|
+
const suffix = output.truncated ? "\n\n[truncated โ the page has more comments/blocks than shown]" : "";
|
|
213
|
+
// OPE-1026 โ THE PROVENANCE LINE, only when there is comment text to date (Linear's pattern,
|
|
214
|
+
// ยงOPE-1012): the comments above are dated against THIS read, and a comment's claim about status
|
|
215
|
+
// or approval is HISTORY, not the page's current state. `read_notion_page` is the tool that reads
|
|
216
|
+
// the page's current content โ this one never claims to.
|
|
217
|
+
const asOf =
|
|
218
|
+
output.comments > 0 && output.readAt
|
|
219
|
+
? `\n\nRead live at ${output.readAt}: the comments above are HISTORY, each dated against this read. A comment's claim about status, approval or progress is not the page's current state โ read the page itself (read_notion_page) for that.`
|
|
220
|
+
: "";
|
|
221
|
+
const body = output.comments === 0 ? header : `${header}\n${output.text}${suffix}${asOf}`;
|
|
222
|
+
// OPE-324 โ `workspaceTrust` (plumbed OPE-331) is the DECISION here, same route and same meaning
|
|
223
|
+
// as read_notion_page's (see its note): a vouched-internal workspace fences without tainting, an
|
|
224
|
+
// unvouched one falls closed and taints, and the `assumed` flag says the operator never vouched
|
|
225
|
+
// it rather than that OE could not look.
|
|
226
|
+
return ingestUntrustedModelOutput(body, {
|
|
227
|
+
source: `notion comments ${output.page}`,
|
|
228
|
+
ingestion: {
|
|
229
|
+
kind: "notion",
|
|
230
|
+
label: "read_notion_comments",
|
|
231
|
+
...(output.workspaceTrust !== undefined ? { workspaceTrust: output.workspaceTrust } : {}),
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
preRegisterDynamicEntry("read_notion_comments", "_single", readNotionCommentsEntry);
|
|
237
|
+
|
|
238
|
+
export default defineDynamic({
|
|
239
|
+
events: {
|
|
240
|
+
// session.started ONLY: a session runs on exactly one surface (see read_notion_page.ts).
|
|
241
|
+
"session.started": (_event, ctx) => {
|
|
242
|
+
// THE CHAT ARM (ยงA5), narrowed to the DELEGATED-WORKER grain by OPE-1014: mount iff `notion โ`
|
|
243
|
+
// this window's sources AND the grain is `task`. This tool has NO work arm, so `task` is its whole
|
|
244
|
+
// mount: a conversational Notion seat reads the same comment layer through the generic
|
|
245
|
+
// `read_channel` / `read_thread`, which sweep a superset of what this tool returns.
|
|
246
|
+
if (!workerChatArmMount(ctx as BindableContext, "notion", () => getConfig().loadDomains())) return null;
|
|
247
|
+
return readNotionCommentsEntry;
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// The doc-body read tool for the ambient Notion surface (notion-ambient-surface-design.md
|
|
2
|
+
// Step 0). The ambient supervisor judges + replies off the COMMENT context (a comment usually
|
|
3
|
+
// anchors to specific text); when a reply needs the doc's own prose โ to answer a question about
|
|
4
|
+
// the page, or to catch a factual problem in it โ the agent pulls the body with this tool. Body
|
|
5
|
+
// BY REFERENCE (chat-vs-ticketing-and-tool-uniformity.md rec #3): the doc is fetched on demand,
|
|
6
|
+
// NEVER inlined into the wake, so a big page can't blow the backscroll budget.
|
|
7
|
+
//
|
|
8
|
+
// SURFACE GATE: this reads a *Notion* page, so it only makes sense on a Notion session. Authored
|
|
9
|
+
// as a per-session DYNAMIC resolver (defineDynamic) that returns the real tool only when the
|
|
10
|
+
// session platform is `notion`, and `null` (the tool is absent, not merely throwing) elsewhere โ
|
|
11
|
+
// the exact discipline access/source-scoped-mount.ts states, so a Slack/Discord agent never sees it.
|
|
12
|
+
//
|
|
13
|
+
// TWO PLANES SINCE OPE-913, and they differ in mount, fence and CREDENTIAL:
|
|
14
|
+
// โข CHAT plane โ a Notion-origin turn reads its own wake container's page on the chat credential
|
|
15
|
+
// (`channel-key`). Narrowed to the DELEGATED-WORKER grain by OPE-1014 โ see the note below.
|
|
16
|
+
// โข WORK plane โ any session whose DOMAIN declares a Notion work credential reads that domain's
|
|
17
|
+
// pages, from any surface, on the WORK credential. This is what makes a Slack-woken delegated
|
|
18
|
+
// worker able to ground itself in the spec it was told to build from (the OPE-913 soak
|
|
19
|
+
// finding). A domain that declares no work credential holds no grant and sees no change.
|
|
20
|
+
// channels/notion-work.ts owns the grant decision; both planes fence the target page to the
|
|
21
|
+
// caller's own domain, and neither can read a page the domain does not own.
|
|
22
|
+
//
|
|
23
|
+
// ๐ด THE CHAT ARM IS THE DELEGATED WORKER'S (OPE-1014), AND THE OPE-988 RULING IS WHY IT IS SAFE TO
|
|
24
|
+
// NARROW. Tommy's ruling in channels/notion-read.ts states that PAGE CONTENT STAYS OUT OF THE CHAT
|
|
25
|
+
// PLANE, and it names the seat's own route in the same breath: "a seat that needs the document
|
|
26
|
+
// itself uses the GRANT-MOUNTED `read_notion_page` or delegates to a worker". Both of those survive
|
|
27
|
+
// this change untouched โ the WORK arm above is grain-blind, and a worker holds the chat arm. What
|
|
28
|
+
// goes is the third route the ruling never named: an UNGRANTED conversational Notion seat reading
|
|
29
|
+
// page prose off the chat credential. That seat now delegates, exactly as the ruling says.
|
|
30
|
+
//
|
|
31
|
+
// The worker keeps the arm for now, though it is no longer the ONLY route (OPE-1064): `WORK_ROLES`
|
|
32
|
+
// gives Notion a CONTAINER row too (the same "team-key" role its entity row already names), so a
|
|
33
|
+
// work-granted domain's delegated worker also holds the generic `read_work_channel` /
|
|
34
|
+
// `read_work_thread` pair โ access/worker-seat.ts still withholds `read_channel` / `read_thread`
|
|
35
|
+
// from it, but the neutral pair now covers what only this vendor arm covered before. The two are
|
|
36
|
+
// NOT identical: this tool returns the whole page (title + body) in one read, the generic pair
|
|
37
|
+
// splits root/thread the Slack way. Retiring this arm in favour of the generic pair entirely is a
|
|
38
|
+
// follow-up (filed on OPE-1064), not this change โ it changes a shipped tool's shape and deserves
|
|
39
|
+
// its own review.
|
|
40
|
+
//
|
|
41
|
+
// DOMAIN FENCE: the `page` argument is a caller-supplied id, and the one deployment-wide Notion
|
|
42
|
+
// credential cannot tell one domain's pages from another's โ so which pages this session may read is
|
|
43
|
+
// OURS to decide, in channels/notion-scope.ts (OPE-236). Fail closed, and every refusal is audited.
|
|
44
|
+
//
|
|
45
|
+
// Output is wrapped untrusted (toModelOutput โ ingestUntrusted): a page body is written by
|
|
46
|
+
// arbitrary workspace members, so the model must treat it as DATA, not instructions.
|
|
47
|
+
|
|
48
|
+
import { defineDynamic, defineTool } from "eve/tools";
|
|
49
|
+
import { z } from "zod";
|
|
50
|
+
import { readNotionPage, readNotionPageIdentity, resolveBlockOwningPage, resolveNotionCredential } from "../channels/notion-api.js";
|
|
51
|
+
import { notionCallerCredential } from "../channels/notion-credentials.js";
|
|
52
|
+
import { assertNotionActingIdentity } from "../channels/notion-acting-identity.js";
|
|
53
|
+
import { assertNotionPageAllowed, assertNotionPageAllowedOrResolveBlock, assertNotionWorkPageAllowed } from "../channels/notion-scope.js";
|
|
54
|
+
import { notionWorkGrant, notionWorkMount, notionWorkspacesOf, type NotionWorkGrant } from "../channels/notion-work.js";
|
|
55
|
+
import {
|
|
56
|
+
ingestUntrustedModelOutput,
|
|
57
|
+
assertTurnSurface,
|
|
58
|
+
TurnOriginFenceError,
|
|
59
|
+
auditOriginFenceRefusal,
|
|
60
|
+
bindFrom,
|
|
61
|
+
workerChatArmMount,
|
|
62
|
+
getConfig,
|
|
63
|
+
preRegisterDynamicEntry,
|
|
64
|
+
resolvePartition,
|
|
65
|
+
declaredWorkspaceTrust,
|
|
66
|
+
noteAuthoredRecordRead,
|
|
67
|
+
type SurfaceTrust,
|
|
68
|
+
type BindableContext,
|
|
69
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
70
|
+
|
|
71
|
+
const MAX_CHARS = 20_000;
|
|
72
|
+
const MAX_BLOCKS = 200;
|
|
73
|
+
|
|
74
|
+
// FACTORED (see access/window-gated.ts's REPLAY DOCTRINE): a top-level defineTool const, never an
|
|
75
|
+
// inline literal in the handler โ eve's bundler transform hoists an inline one under a
|
|
76
|
+
// build-order-counter name that shifts across deploys. eve derives a session-scoped step id for a
|
|
77
|
+
// factored, unstamped entry and self-heals it on cold resume (OPE-867). Exported for the read-scope
|
|
78
|
+
// test, which drives execute() to prove the DOMAIN FENCE is actually
|
|
79
|
+
// WIRED here โ a fence the tool forgets to call is worth nothing, and the module test alone would
|
|
80
|
+
// not notice its removal.
|
|
81
|
+
export const readNotionPageEntry = defineTool({
|
|
82
|
+
description:
|
|
83
|
+
"Read the text of a Notion page (this doc, or another page shared with the integration). " +
|
|
84
|
+
"Use it when a reply needs the doc's own content โ to answer a question about the page or " +
|
|
85
|
+
"catch a problem in it. Returns the page's top-level text (truncated if very long); the " +
|
|
86
|
+
"comment thread you were woken on is already in your context. It reads under the shared " +
|
|
87
|
+
"team integration, never under the asker's own Notion account, so it sees what the team " +
|
|
88
|
+
"integration was shared and not what they can see.",
|
|
89
|
+
inputSchema: z.object({
|
|
90
|
+
page: z
|
|
91
|
+
.string()
|
|
92
|
+
.min(1)
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("The Notion page id to read (e.g. a UUID). Defaults to THIS doc โ the page the comment you're on lives in."),
|
|
95
|
+
}),
|
|
96
|
+
outputSchema: z.object({
|
|
97
|
+
page: z.string(),
|
|
98
|
+
// OPE-1714 โ the page's own title and Notion url, from a separate GET on the page object
|
|
99
|
+
// (the body read returns blocks alone). The model gets a name and a link it can hand a
|
|
100
|
+
// human instead of a bare uuid. Optional both ways: a stored output from before these
|
|
101
|
+
// fields existed still renders, and the identity fetch is fail-soft, so a page whose
|
|
102
|
+
// object could not be read renders exactly as it did before. UNTRUSTED โ a workspace
|
|
103
|
+
// member chooses the title, and toModelOutput wraps the whole render accordingly.
|
|
104
|
+
title: z.string().optional(),
|
|
105
|
+
url: z.string().optional(),
|
|
106
|
+
blocks: z.number(),
|
|
107
|
+
truncated: z.boolean(),
|
|
108
|
+
text: z.string(),
|
|
109
|
+
// OPE-1026 โ the moment the fetch below returned, stamped by execute and carried to
|
|
110
|
+
// toModelOutput. Optional for the same reason `read_linear_issue.ts`'s `readAt` is: a
|
|
111
|
+
// stored output from before this field existed still renders, with no invented read time.
|
|
112
|
+
readAt: z.string().optional(),
|
|
113
|
+
// OPE-331 โ the BOUND WORKSPACE's declared tier, carried from execute (which has a ctx) to
|
|
114
|
+
// toModelOutput (which does not). It is the input the source-tier table's `notion` row needs, and
|
|
115
|
+
// the reason every row this seam produced read `assumed`. Model-INVISIBLE: an authored
|
|
116
|
+
// toModelOutput means the projection is what the model sees, never this object. Absent when
|
|
117
|
+
// the operator has not vouched the workspace โ see declaredWorkspaceTrust for why silence
|
|
118
|
+
// stays absent rather than becoming `"public"`.
|
|
119
|
+
workspaceTrust: z.enum(["internal", "public"]).optional(),
|
|
120
|
+
}),
|
|
121
|
+
// NO `approval:` โ a dynamic-tool approval fn only registers at session.started resolve
|
|
122
|
+
// time, so a resumed session's replay lookup fails and eve kills the turn (the same pin
|
|
123
|
+
// access/window-gated.ts documents). Read-only, so absent approval === never() is correct.
|
|
124
|
+
async execute(input, ctx2) {
|
|
125
|
+
const domains = getConfig().loadDomains();
|
|
126
|
+
// Per-turn surface fence (finding 1b): on a unified session a non-Notion-origin turn
|
|
127
|
+
// must not read Notion pages on the CHAT plane, even though the tool is armed for the
|
|
128
|
+
// session. OPE-732: the fence's refusal is audited here โ assertTurnSurface itself stays
|
|
129
|
+
// a pure, synchronous, store-free check (see identity/binding.ts).
|
|
130
|
+
//
|
|
131
|
+
// OPE-913 โ THE WORK PLANE IS THE ONE WAY PAST IT. A non-Notion-origin turn is admitted
|
|
132
|
+
// only when the caller's DOMAIN holds the Notion work grant (channels/notion-work.ts).
|
|
133
|
+
// That is not a hole in the fence: the grant is a reviewed config declaration naming the
|
|
134
|
+
// credential the read runs under, and the target page is still fenced to the caller's own
|
|
135
|
+
// domain below (assertNotionWorkPageAllowed). With no grant the refusal is byte-identical
|
|
136
|
+
// to before โ same audit row, same thrown error.
|
|
137
|
+
let workGrant: NotionWorkGrant | undefined;
|
|
138
|
+
try {
|
|
139
|
+
assertTurnSurface(ctx2 as BindableContext, "notion");
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (!(err instanceof TurnOriginFenceError)) throw err;
|
|
142
|
+
workGrant = notionWorkGrant(ctx2 as BindableContext, domains);
|
|
143
|
+
if (!workGrant) {
|
|
144
|
+
await auditOriginFenceRefusal(ctx2 as BindableContext, "notion");
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Default to THIS session's page (bindFrom(ctx).surface = the page id for Notion โ see
|
|
149
|
+
// identity/binding.ts bindNotion). On the WORK plane there is no such default to take:
|
|
150
|
+
// the caller's surface is a Slack channel / a repo / a Linear team, and `bindFrom().surface`
|
|
151
|
+
// would hand a non-Notion id to the Notion API. Require the argument instead of guessing.
|
|
152
|
+
const requestedPage = input.page ?? (workGrant ? undefined : bindFrom(ctx2 as BindableContext).surface);
|
|
153
|
+
if (!requestedPage) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
workGrant
|
|
156
|
+
? "Pass `page` explicitly โ this session was not woken on a Notion page, so there is no page to default to."
|
|
157
|
+
: "No Notion page id available for this session โ pass `page` explicitly.",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
// Credential resolved BEFORE the fence (OPE-352): resolving a block/discussion id to its
|
|
161
|
+
// owning page needs a working Notion API call, so the resolver has to be built first. This
|
|
162
|
+
// does NOT reintroduce a network dependency into a plain refusal โ when there is no key, the
|
|
163
|
+
// resolver below is a no-op stub (never calls fetch), so an out-of-scope id still refuses
|
|
164
|
+
// with zero network and zero key, exactly as before (pinned in notion-read-scope-test.ts ยง6).
|
|
165
|
+
// OPE-467 / OPE-499 โ a delivery-woken session reads under the credential that VERIFIED
|
|
166
|
+
// the delivery (carried on the auth attributes); an agent-initiated session (no delivery)
|
|
167
|
+
// falls back to the CALLING domain's own binding, or the org default when it binds none.
|
|
168
|
+
// Resolved off the caller's OWN surface/context, known before the fence runs (the
|
|
169
|
+
// block-resolution walk below needs a working key to run at all). That is not a weaker
|
|
170
|
+
// check: notion-scope.ts only ever admits the caller's own page or a page in the caller's
|
|
171
|
+
// own domain, so caller and target resolve to the same domain by construction.
|
|
172
|
+
// OPE-505: the key AND its endpoint rules, resolved together and spread together, so the
|
|
173
|
+
// credential's fence reaches every REST call below.
|
|
174
|
+
//
|
|
175
|
+
// OPE-913 โ THE WORK PLANE RUNS ON A DIFFERENT CREDENTIAL, and that is the point of the
|
|
176
|
+
// split. `notionCallerCredential` resolves the CHAT key (`channel-key`, the comment
|
|
177
|
+
// surface's plumbing). A work-granted read runs on the credential the DOMAIN declared for
|
|
178
|
+
// its work role, so an organisation can share its chat integration with the comment pages
|
|
179
|
+
// only and its work integration with the page trees the agent may build from.
|
|
180
|
+
//
|
|
181
|
+
// ๐ด ONE VARIABLE FEEDS BOTH THE RESOLVE AND THE FAILURE MESSAGE, deliberately. Naming the
|
|
182
|
+
// credential in a literal string that a second expression resolves is how a test comes to
|
|
183
|
+
// assert a plane it is not actually exercising: the message says "work credential" while
|
|
184
|
+
// the call resolves the chat one, and nothing notices. Interpolating `credentialId` makes
|
|
185
|
+
// the message an OBSERVABLE of the resolution instead of a claim about it.
|
|
186
|
+
//
|
|
187
|
+
// ๐ด OPE-1525 โ THE OWN-ACCOUNT GATE RUNS FIRST, ON THE LINE BELOW'S BEHALF. Both arms of
|
|
188
|
+
// the choice below resolve a TEAM key, so a member who asked to act with THEIR OWN Notion
|
|
189
|
+
// account has no answer here โ and answering anyway is the defect the ticket traced. The
|
|
190
|
+
// gate refuses that turn (naming the consent route, or the granted tool, or saying plainly
|
|
191
|
+
// that neither exists) and writes the `connector.acting_identity` row either way.
|
|
192
|
+
await assertNotionActingIdentity(ctx2 as BindableContext, "read_notion_page");
|
|
193
|
+
const credentialId = workGrant ? workGrant.credential : notionCallerCredential(ctx2 as BindableContext, domains);
|
|
194
|
+
const cred = await resolveNotionCredential(credentialId);
|
|
195
|
+
// DOMAIN FENCE (OPE-236 + OPE-352). Notion's own enforcement bounds the WORKSPACE โ one
|
|
196
|
+
// deployment-wide integration key, identical for every domain โ so it cannot tell domain A's
|
|
197
|
+
// pages from domain B's. channels/notion-scope.ts is the allowlist on top: own page always,
|
|
198
|
+
// otherwise caller and target must land in the SAME domain (roster, then a container entry
|
|
199
|
+
// claim), and an unresolvable page is REFUSED, never defaulted readable. The refusal is
|
|
200
|
+
// audited there โ the gap this closes was silent.
|
|
201
|
+
//
|
|
202
|
+
// OPE-352: the Notion worker intermittently hands this tool a DISCUSSION BLOCK id instead of
|
|
203
|
+
// the page it lives on, and a block id fails the plain page fence every time (it is never
|
|
204
|
+
// itself rostered/claimed) โ a silent-looking `target-unassigned` refusal even when the
|
|
205
|
+
// block's owning page was perfectly in scope. With a working credential, fall back to
|
|
206
|
+
// resolving `requestedPage` as a block id to its parent page (bounded walk, see
|
|
207
|
+
// resolveBlockOwningPage) and fence THAT page through the exact same decision โ never a
|
|
208
|
+
// separate, looser check. `page` below is the id actually authorized (the original id, or
|
|
209
|
+
// the resolved parent page when only resolution admitted it) โ the content fetch and the
|
|
210
|
+
// reported `page` field both use it, never the raw `requestedPage`.
|
|
211
|
+
//
|
|
212
|
+
// OPE-913 โ THE WORK PLANE TAKES THE PLAIN FENCE, deliberately. `notionReadScope` reads the
|
|
213
|
+
// caller's domain out of its own Notion surface key, which a Slack-woken caller does not
|
|
214
|
+
// have, so the work plane fences through `notionWorkScope` instead: the caller's domain
|
|
215
|
+
// comes from whatever surface woke it, and the target must still land in THAT domain. No
|
|
216
|
+
// block-parent resolution hop here โ the OPE-352 hop exists because the Notion COMMENT
|
|
217
|
+
// worker is handed discussion-block ids by its own wake, which a cross-surface caller never
|
|
218
|
+
// is. A block id from the work plane refuses, and the refusal says how to pass a page id.
|
|
219
|
+
let page: string;
|
|
220
|
+
if (workGrant) {
|
|
221
|
+
await assertNotionWorkPageAllowed(
|
|
222
|
+
ctx2 as BindableContext,
|
|
223
|
+
requestedPage,
|
|
224
|
+
workGrant.domain,
|
|
225
|
+
domains,
|
|
226
|
+
notionWorkspacesOf(workGrant.domain),
|
|
227
|
+
);
|
|
228
|
+
page = requestedPage;
|
|
229
|
+
} else if (cred) {
|
|
230
|
+
page = await assertNotionPageAllowedOrResolveBlock(ctx2 as BindableContext, requestedPage, domains, (id) => resolveBlockOwningPage(id, { ...cred }));
|
|
231
|
+
} else {
|
|
232
|
+
await assertNotionPageAllowed(ctx2 as BindableContext, requestedPage, domains);
|
|
233
|
+
page = requestedPage;
|
|
234
|
+
}
|
|
235
|
+
if (!cred) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
workGrant
|
|
238
|
+
? `no value stored for this domain's Notion work credential "${credentialId}" โ cannot read the page.`
|
|
239
|
+
: "no value stored under db:notion:channel-key โ cannot read the page.",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const res = await readNotionPage(page, { ...cred, maxChars: MAX_CHARS, maxBlocks: MAX_BLOCKS });
|
|
243
|
+
// OPE-1026 โ the moment the fetch above returned. Stamped AFTER the fetch, so it never
|
|
244
|
+
// claims a read is fresher than it is (see read_linear_issue.ts's `readAt`).
|
|
245
|
+
const readAt = new Date().toISOString();
|
|
246
|
+
// OPE-331 โ resolve the workspace tier HERE, where a ctx exists, and carry it on the
|
|
247
|
+
// output. Read-only and fail-soft: an unresolvable partition leaves it absent, which is the
|
|
248
|
+
// fail-closed answer the table already had.
|
|
249
|
+
//
|
|
250
|
+
// ๐ด OPE-913 โ THE WORK PLANE LEAVES IT ABSENT, AND THAT IS THE WHOLE POINT. This resolves
|
|
251
|
+
// the tier of the CALLER'S OWN partition, which on the chat plane IS the Notion workspace
|
|
252
|
+
// the page lives in. On the work plane the caller is a Slack channel, a repo, a Linear
|
|
253
|
+
// team โ so the same line would hand this Notion page the tier the operator vouched for
|
|
254
|
+
// SLACK, and a vouched Slack workspace would silently un-taint a page read from an
|
|
255
|
+
// unvouched Notion workspace. Absent means the read TAINTS (see toModelOutput below), which
|
|
256
|
+
// is the correct answer for a tier we genuinely cannot resolve here.
|
|
257
|
+
let workspaceTrust: SurfaceTrust | undefined;
|
|
258
|
+
if (!workGrant) {
|
|
259
|
+
try {
|
|
260
|
+
workspaceTrust = declaredWorkspaceTrust(resolvePartition(ctx2 as BindableContext).workspaceKey);
|
|
261
|
+
} catch {
|
|
262
|
+
workspaceTrust = undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// OPE-1714 โ the page's own title and url, on the SAME credential and the SAME fenced page
|
|
266
|
+
// id the body read used. Separate call because the block-children endpoint carries neither;
|
|
267
|
+
// fail-soft inside the helper, so a page whose object cannot be read still returns its body.
|
|
268
|
+
const identity = await readNotionPageIdentity(page, { ...cred });
|
|
269
|
+
// OPE-1714 โ THE REPLY FOOTER'S RECORD HALF, recorded HERE because here is where the host
|
|
270
|
+
// knows what it fetched. The door writes one reply-provenance entry (`notion`,
|
|
271
|
+
// `read_notion_page`) and decides on its own, from this turn's `page_id` stamp, whether the
|
|
272
|
+
// room already holds this page's id โ a page the room never named is recorded WITHOUT its
|
|
273
|
+
// id, so it can never be footed. The title is turn-local and never stored. Never throws.
|
|
274
|
+
noteAuthoredRecordRead(ctx2 as BindableContext, {
|
|
275
|
+
kind: "notion-page",
|
|
276
|
+
id: page,
|
|
277
|
+
// The id the CALLER asked for as well as the one the fence authorized: OPE-352's hop
|
|
278
|
+
// resolves a discussion-block id to its owning page, and the room's stamp names the
|
|
279
|
+
// block. Both name the record this read pulled.
|
|
280
|
+
alsoKnownAs: [requestedPage],
|
|
281
|
+
name: identity.title,
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
page,
|
|
285
|
+
...(identity.title !== undefined ? { title: identity.title } : {}),
|
|
286
|
+
...(identity.url !== undefined ? { url: identity.url } : {}),
|
|
287
|
+
blocks: res.blockCount,
|
|
288
|
+
truncated: res.truncated,
|
|
289
|
+
text: res.text,
|
|
290
|
+
readAt,
|
|
291
|
+
...(workspaceTrust !== undefined ? { workspaceTrust } : {}),
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
toModelOutput(output) {
|
|
295
|
+
// OPE-1714 โ name the page and link it where the identity fetch answered. `ref` degrades to
|
|
296
|
+
// the bare id, which is exactly what every render said before these fields existed.
|
|
297
|
+
const ref = [output.title, output.url].filter((v) => typeof v === "string" && v.length > 0).join(" โ ");
|
|
298
|
+
const named = ref.length > 0 ? `${output.page} (${ref})` : output.page;
|
|
299
|
+
const header = output.text.length === 0 ? `Notion page ${named} has no readable text.` : `Notion page ${named}:`;
|
|
300
|
+
const suffix = output.truncated ? "\n\n[truncated โ the page has more content than shown]" : "";
|
|
301
|
+
// OPE-1026 โ THE PROVENANCE LINE (the pattern OPE-1012 shipped for Linear, ยงasOf). Only
|
|
302
|
+
// when there is text to date: this is the page's CURRENT content, as of this read โ the
|
|
303
|
+
// counterpart to read_notion_comments, whose comments are HISTORY against the same clock.
|
|
304
|
+
const asOf =
|
|
305
|
+
output.text.length > 0 && output.readAt ? `\n\nRead live at ${output.readAt}: the text above is the page's current content, as of that moment.` : "";
|
|
306
|
+
const body = output.text.length === 0 ? header : `${header}\n${output.text}${suffix}${asOf}`;
|
|
307
|
+
// ๐ด OPE-324 โ `workspaceTrust` (plumbed OPE-331) IS NOW THE DECISION. A page read from a
|
|
308
|
+
// workspace the operator VOUCHED as internal fences without tainting; an unvouched or
|
|
309
|
+
// public workspace still taints. Tommy ratified that relaxation on 2026-08-09 โ a vouched
|
|
310
|
+
// internal Notion workspace is org property with org membership, the same trust basis as a
|
|
311
|
+
// private channel, and the instance-binary rule says so.
|
|
312
|
+
//
|
|
313
|
+
// `toModelOutput` has no ctx to resolve config from, so execute resolves the bound
|
|
314
|
+
// workspace's declared tier and carries it on the output. Absent โ the table falls closed
|
|
315
|
+
// and the read TAINTS, and the `assumed` flag says why: the operator has never vouched this
|
|
316
|
+
// workspace (agent/identity/surfaces.ts), not that OE could not look.
|
|
317
|
+
return ingestUntrustedModelOutput(body, {
|
|
318
|
+
source: `notion page ${output.page}`,
|
|
319
|
+
ingestion: {
|
|
320
|
+
kind: "notion",
|
|
321
|
+
label: "read_notion_page",
|
|
322
|
+
...(output.workspaceTrust !== undefined ? { workspaceTrust: output.workspaceTrust } : {}),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
preRegisterDynamicEntry("read_notion_page", "_single", readNotionPageEntry);
|
|
328
|
+
|
|
329
|
+
export default defineDynamic({
|
|
330
|
+
events: {
|
|
331
|
+
// session.started ONLY: a session runs on exactly one surface, so the platform can't change
|
|
332
|
+
// mid-session โ resolving once is correct and cheaper (see access/source-scoped-mount.ts).
|
|
333
|
+
"session.started": (_event, ctx) => {
|
|
334
|
+
// THE CHAT ARM (ยงA5, see access/source-scoped-mount.ts), narrowed to the DELEGATED-WORKER grain by
|
|
335
|
+
// OPE-1014: mount iff `notion โ` this window's sources AND the grain is `task`. The per-turn
|
|
336
|
+
// origin fence in execute (assertTurnSurface) still stops a non-Notion-origin turn reading a
|
|
337
|
+
// page on a multi-source window.
|
|
338
|
+
//
|
|
339
|
+
// OPE-913 โ OR the caller's DOMAIN holds the Notion WORK grant. That is the second, independent
|
|
340
|
+
// mount path, and it is GRAIN-BLIND: a Slack-woken delegated worker in a work-granted domain
|
|
341
|
+
// mounts this tool even though `notion โ sources` for its window, and so does a granted
|
|
342
|
+
// conversational seat. Without the grant nothing widens โ a domain that declares no Notion work
|
|
343
|
+
// credential mounts this tool on its delegated workers only.
|
|
344
|
+
const ok =
|
|
345
|
+
workerChatArmMount(ctx as BindableContext, "notion", () => getConfig().loadDomains()) ||
|
|
346
|
+
notionWorkMount(ctx as BindableContext, () => getConfig().loadDomains());
|
|
347
|
+
if (!ok) return null; // hide unless notion โ sources, or the domain holds the work grant
|
|
348
|
+
return readNotionPageEntry;
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type BindableContext } from "@open-everywhere/core/agent/adapter-api.js";
|
|
2
|
+
/**
|
|
3
|
+
* Refuse this Notion read when the requester asked to act with their OWN Notion account.
|
|
4
|
+
*
|
|
5
|
+
* Throws the gate's own sentence โ which names the consent route when one exists here, says plainly
|
|
6
|
+
* that there is none when it does not, names the granted tool when the account is already lent, and
|
|
7
|
+
* asks which account was meant when one sentence named both. Returns normally on every other turn,
|
|
8
|
+
* which is the ordinary path and is unchanged.
|
|
9
|
+
*
|
|
10
|
+
* ๐ด IT ALWAYS WRITES THE `connector.acting_identity` ROW, INCLUDING ON THE ORDINARY PATH. A trail
|
|
11
|
+
* that records only refusals cannot answer "which account read this page", which is the question
|
|
12
|
+
* OPE-1525 was filed for.
|
|
13
|
+
*
|
|
14
|
+
* ๐ด THE `install` CHECK IS STRUCTURAL, NOT DECORATION. The gate's contract is "refuse on `refusal`,
|
|
15
|
+
* otherwise run under `identity`" โ and every caller of this helper goes on to resolve the
|
|
16
|
+
* DEPLOYMENT's credential, which is the `install` identity and the only one it can spend. Reading
|
|
17
|
+
* only the refusal would keep that contract by convention: the day a caller passes
|
|
18
|
+
* `callerCanSpendPersonal`, the gate would return `identity: "human-acting"`, this helper would
|
|
19
|
+
* return normally, and the read would run on the install key โ the exact OPE-1525 defect, re-entered
|
|
20
|
+
* through the fix for it. So the precondition is asserted here rather than remembered.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assertNotionActingIdentity(ctx: BindableContext, tool: string): Promise<void>;
|
|
23
|
+
//# sourceMappingURL=notion-acting-identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notion-acting-identity.d.ts","sourceRoot":"","sources":["../../../agent/channels/notion-acting-identity.ts"],"names":[],"mappings":"AAwCA,OAAO,EAAuD,KAAK,eAAe,EAAyB,MAAM,4CAA4C,CAAC;AAa9J;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAclG"}
|