feat(core): crispChannel private notes — crisp_send_note, replyAs: "note", post note: true - #158
Conversation
…uman operators
Crisp's note type (same message endpoint, operator-only, invisible to the
visitor) becomes a first-class outbound, one seam per authorship:
- crisp_send_note tool: the agent hands reference context / findings / a
preliminary assessment to the human team mid-turn, even when it should
not (or cannot) reply to the visitor
- replyAs: "note": the supervised-rollout mode — turns run normally but
the ENTIRE reply path lands as operator-only drafts; flip back to
"message" to go live
- post(target, { text, note: true }): deterministic app-authored notes
(e.g. a mirror-mode shadow turn's output). Slack's post fails closed on
note: true — downgrading operator-only content to a public message
would leak it
Also fixes post() hardcoding X-Crisp-Tier: "plugin" — the tier option's
contract is that it rides on every outbound call; all sends now share one
postMessage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds private Crisp notes for agent-, channel-, and app-authored outbound messages.
Changes:
- Adds
crisp_send_noteandreplyAs: "note". - Extends
post()with private notes and fail-closed Slack behavior. - Unifies Crisp sending and honors configured API tiers.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
packages/core/src/channels.ts |
Implements private-note delivery. |
packages/core/src/agent-config.ts |
Adds the note post option. |
packages/core/test/channels.test.ts |
Tests note delivery and guards. |
.changeset/crisp-private-notes.md |
Records the patch release. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const tier = opts.tier ?? "plugin"; | ||
| async function sendMessage(websiteId: string, sessionId: string, content: string) { | ||
| await fetch(`${api}/website/${websiteId}/conversation/${sessionId}/message`, { | ||
| const replyAs: "message" | "note" = opts.replyAs ?? "message"; |
There was a problem hiding this comment.
Adopted in 6ae92fd — agreed this is a confidentiality boundary, so it now fails closed at construction (replyAs must be "message" or "note"), following the auth-mode check's "backstop for plain-JS callers" precedent. Covered by a test constructing with replyAs: "notes".
| if (r.error !== false) return { error: r.reason ?? "crisp error" }; | ||
| // fingerprint = the note's message identity, so the agent can reference it later | ||
| return { ok: true, fingerprint: r.data?.fingerprint }; |
There was a problem hiding this comment.
Adopted in 6ae92fd — a success envelope without a fingerprint now returns { error } instead of { ok: true, fingerprint: undefined }, aligned with post()'s strictness. Test added for the { error: false, data: {} } shape.
| calls = []; | ||
| globalThis.fetch = (async (url: unknown, init?: { body?: string }) => { | ||
| calls.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : undefined }); | ||
| return Response.json({ error: false, reason: "dispatched", data: { fingerprint: 777 } }); | ||
| }) as typeof fetch; | ||
| const crisp = crispChannel({ signingSecret: secret, identifier: "id", key: "key", apiUrl: "https://crisp.test" }); | ||
| // e.g. a mirror-mode app posting its shadow turn's assessment for the human team | ||
| const posted = await crisp.post!({ channelId: "w1", threadId: "s1" }, { text: "shadow assessment: refund warranted", note: true }); | ||
| expect(calls[0]!.body).toMatchObject({ type: "note", from: "operator", content: "shadow assessment: refund warranted" }); | ||
| expect(posted).toEqual({ channelId: "w1", threadId: "s1", ts: "777" }); |
There was a problem hiding this comment.
Adopted in 6ae92fd — added a dedicated regression test: post() with tier: "website" asserts X-Crisp-Tier on both a text post and a note: true post, so the hardcoded-header bug cannot silently return even if the shared postMessage is ever split apart again.
…rprint, post tier regression test
- replyAs is a confidentiality boundary: an untyped typo ("notes") used to
fall through to the visitor-visible branch, making a supervised deployment
public. Now throws at construction, like the auth-mode backstop.
- crisp_send_note: a success envelope without a fingerprint reported
{ ok: true, fingerprint: undefined } — a false success. Now an error,
matching post().
- the X-Crisp-Tier post() fix now has its own regression test (tier:
"website" asserted on post, text + note).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
packages/core/src/channels.ts:1560
crisp_send_notenow feeds model-suppliedwebsiteId/sessionIdvalues into this URL. Without path encoding, values containing/,?,#, or dot segments can change the authenticated REST endpoint instead of identifying a conversation. Encode both path segments before interpolation.
const res = await fetch(`${api}/website/${websiteId}/conversation/${sessionId}/message`, {
| headers: { "content-type": "application/json", authorization: auth(), "X-Crisp-Tier": "plugin" }, | ||
| body: JSON.stringify({ type: "text", from: "operator", origin: "chat", content: text }), | ||
| })).json().catch(() => ({}))) as { error?: boolean; reason?: string; data?: { fingerprint?: number } }; | ||
| const asNote = typeof content !== "string" && content.note === true; |
There was a problem hiding this comment.
Adopted in 10220c5 — agreed: same confidentiality argument as the replyAs guard, and crisp's strict === true was the one place a { note: "true" } fell through to the PUBLIC branch. Any non-boolean note now throws loudly on both channels (slack's truthy check had the inverse inconsistency — it accepted "true" but silently passed falsy non-booleans). Test covers note: "true" (crisp) and note: "" (slack) and asserts nothing left the process.
…hannels
The `note` flag is a confidentiality marker: an untyped caller passing
{ note: "true" } used to fall through crisp's strict `=== true` check into
the visitor-visible text branch (and slack's truthy check silently ACCEPTED
it while rejecting a falsy non-boolean). Anything but boolean/absent now
throws loudly on both channels — the post() dual of the replyAs
construction guard.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
packages/core/src/channels.ts:1569
- A valid JSON
nullresponse is returned here despite the object cast; bothpost()andcrisp_send_notethen dereferencer.error, causing a TypeError instead of their documented fail-closed platform error. Normalize non-object envelopes to{}as well as handling JSON parse failures.
return (await res.json().catch(() => ({}))) as { error?: boolean; reason?: string; data?: { fingerprint?: number } };
packages/core/src/agent-config.ts:171
- Widening the shared
PostContentobject does not make private delivery fail closed for exported/customChannelimplementations. Existing implementations that postcontent.textand ignore unknown fields continue to type-check, so{ text, note: true }can be published on any non-Crisp channel despite this contract. Expose notes through a separate optional capability/method, or another channel-discriminated API that unsupported implementations cannot silently accept.
export type PostContent = string | { text?: string; blocks?: unknown[]; note?: boolean };
| // endpoint, differing only in `type`. Returns Crisp's envelope so callers pick | ||
| // their own strictness: the reply path stays best-effort, post/tool check it. | ||
| async function postMessage(websiteId: string, sessionId: string, content: string, type: "text" | "note") { | ||
| const res = await fetch(`${api}/website/${websiteId}/conversation/${sessionId}/message`, { |
There was a problem hiding this comment.
Adopted in 49d76dd — real injection surface (visitor message → prompt injection → tool args). Added crispPathSegment (encodeURIComponent + blank-id rejection) at all interpolation sites: both tools, postMessage, and fetchIdentity — note crisp_read_conversation had the same pre-existing hole, fixed alongside. Test asserts a "../../plugin/evil" session id stays one encoded segment and blank ids return the model-readable error with nothing sent.
| // replyAs is a CONFIDENTIALITY boundary: a plain-JS typo ("notes") falling through | ||
| // to the visitor-visible branch would make a supervised deployment public. Fail at | ||
| // construction, like the auth-mode check — a backstop for untyped callers. | ||
| const replyAs: "message" | "note" = opts.replyAs ?? "message"; |
There was a problem hiding this comment.
Adopted in 49d76dd — agreed: undefined = genuinely absent (default ok), an explicit null is a confused caller and must not slide through ?? into the visitor-visible default. Now only undefined defaults; null (and any other supplied garbage) fails at construction. Test covers replyAs: null.
… closed - websiteId/sessionId reach crisp_send_note and crisp_read_conversation as MODEL-SUPPLIED arguments — raw interpolation let reserved characters or ".." segments retarget the authenticated call. crispPathSegment encodes every id at ALL interpolation sites (tools, postMessage, fetchIdentity — the read tool had the same pre-existing hole); blank ids resolve to the model-readable no-target error before any request. - replyAs: an explicit null slid through ?? into the visitor-visible default; only a genuinely absent option (undefined) defaults now, any supplied invalid value fails at construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| function crispPathSegment(id: string): string { | ||
| if (!id.trim()) throw new Error("crisp: empty id in REST path"); | ||
| return encodeURIComponent(id); | ||
| } |
There was a problem hiding this comment.
已採納 ✅ 這是真實漏洞。encodeURIComponent(".") / encodeURIComponent("..") 都不會 escape .,所以模型提供的 sessionId: ".." 編碼後仍是 ..,URL 正規化會把 /conversation/../message 收合成 /website/w1/message,改寫了已驗證的呼叫目標。
修法(commit 內):
crispPathSegment在編碼前先擋掉 trim 後恰為.或..的 id(丟出錯誤),blank id 維持原有行為。- 工具的
target()也把./..視為「無有效目標」,比照 blank:回傳模型可讀的錯誤、不發送任何請求,而非丟例外。 - traversal regression test 擴充涵蓋
.與..兩個值,並同時驗證crisp_send_note與crisp_read_conversation。
已驗證:core typecheck 乾淨、445 tests pass。
encodeURIComponent leaves "." unescaped, so a model-supplied id of "." or ".." survives encoding and URL normalization then collapses the segment away (/conversation/../message -> /message), retargeting the authenticated Crisp call. crispPathSegment now rejects exact "."/".." outright before encoding, and the tools' target() treats such ids as having no valid target (model-readable error, nothing sent) — matching the blank-id behavior. Extends the traversal regression test to cover both dot values on crisp_send_note and crisp_read_conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/core/src/channels.ts:1778
- Optional chaining only handles missing content; a model-supplied number or object has no
trimmethod and throws, which aborts the turn rather than surfacing a tool error. The agent runtime does not validate tool calls against the declared schema before invokingrun, so this needs a runtime string check.
if (!input.content?.trim()) return { error: "note content is empty" };
packages/core/src/channels.ts:1737
target()handles model-supplied values, but this predicate assumes they are strings and still accepts malformed UTF-16 thatencodeURIComponentrejects. Tool inputs are dispatched directly without runtime schema validation (packages/core/src/agent-runtime.ts:644), sowebsiteId: 1throws here andwebsiteId: "\ud800"throws later incrispPathSegment, aborting the turn instead of returning the intended model-readable no-conversation result. Validate the runtime type and ensure encoding succeeds during preflight.
This issue also appears on line 1778 of the same file.
const usable = (id?: string) => !!id && id.trim() !== "" && id.trim() !== "." && id.trim() !== "..";
Crisp's private notes (same message endpoint,
type: "note"— rendered in the operator inbox only, never shown to the visitor) become a first-class outbound ofcrispChannel. The motivating scenario: an agent running in mirror/observe mode, or not yet trusted to answer visitors autonomously, uses notes to hand reference context and a preliminary assessment to the human operators in-conversation.Three seams, one per authorship
crisp_send_notetool (agent-authored, mid-turn) — the agent leaves a private note for the human team; website/session default from the current turn's event, gated onsource === "crisp"(same posture ascrisp_read_conversation). Works in observe mode too: a shadow pipeline running its own turns fromonEventgets the same tool.replyAs: "note"(channel-authored, per reply) — the supervised-rollout mode: turns run normally, but the entire reply path lands as operator-only drafts. Humans review the agent's answers in-conversation; the visitor sees nothing. Flip back to"message"(default) to go live. Orthogonal torespondToand the tool.post(target, { text, note: true })(app-authored, deterministic) — e.g. a mirror-mode app posting its shadow turn's output as a note, no LLM choice in the loop.PostContentgains the optionalnoteflag; Slack'spostfails closed on it — silently downgrading operator-only content to a public message would leak it.Also
post()hardcodingX-Crisp-Tier: "plugin"— thetieroption's documented contract is "rides on every outbound call". All Crisp sends now share onepostMessage(text and note differ only intype; reply path stays best-effort, post/tool check the envelope).@junejs/corepatch.Tests
crisp_send_note: poststype: "note"with event-defaulted targets, returns the fingerprint; guards (no Crisp event / blank content / Crisp error envelope) surface errors to the model instead of throwingreplyAs: "note": the turn reply lands as a note, visitor-invisiblepostwithnote: true: Crisp sends a note; Slack throws