logancyang_obsidian-copilot/src/agentMode/backends/shared/agentSystemPrompt.ts
Logan Yang a78b38c8c2
feat(entitlement): gate multi-agent to Plus tier via signed entitlement token (#2644)
* feat(entitlement): gate multi-agent to Plus tier via signed entitlement token

Multi-agent fan-out was gated on the binary isPlusUser flag, which any valid
license flips true, so a future Lite tier would wrongly pass. Introduce a
server-signed entitlement token (ES256, verified offline via WebCrypto) as the
single entitlement primitive.

- Rename the broad flag isPlusUser to isPaidUser (any paid license, incl. Lite);
  all existing Plus-feature gates move to it, preserving current behavior.
- Add a new strict isPlusUser (tier >= Plus) derived from the token's
  multi_agent feature. The multi-agent UI gate and the authoritative
  send-boundary check (ensureMultiAgentEntitlement) now key on it.
- No-token fallback (isPlusUser = isPaidUser) preserves today's behavior until
  the server ships tokens alongside Lite/Pro, so there is no plan-name list in
  the public client to patch out.
- Settings sanitize migration backfills isPaidUser from the legacy isPlusUser.
- src/entitlement/ verifies the JWS signature with an embedded public key (no
  key can mint tokens client-side); forged data.json / faked responses fail.

Tests cover tier gating across Free/Lite/Plus/Pro plus the crypto layer
(valid, expired, wrong-user, tampered, unknown-kid, malformed).

Self-host migration to the token is deferred to logancyang/obsidian-copilot-preview#201.
Closes logancyang/obsidian-copilot-preview#200.
Design: "Copilot Entitlement Token Design".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): swap in Node WebCrypto when jsdom's subtle lacks generateKey

CI (Node 22) ships a jsdom whose window.crypto.subtle exists but is partial
(no generateKey), so the previous `!subtle` guard skipped the polyfill and the
entitlement crypto tests failed. Probe for the actual method and install Node's
complete WebCrypto when it's missing. Runtime is unaffected (test-only setup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): never downgrade on an unverifiable token (Codex P1/P2)

Addresses two Codex findings:

- P1: when /license starts returning a token but the client's public keys are
  not shipped yet (or during kid rotation), every token verified as null and
  applyEntitlement cleared the flags, dropping paying users to free. Now
  applyEntitlement never clears: it returns false when it cannot verify, and
  validateLicenseKey falls back to turnOnPaid() for a license the server already
  confirmed valid. Only an authoritative negative (invalid license / no key)
  downgrades, via turnOffPaid.
- P2: removed refreshEntitlementFromCache() and its concurrent startup call,
  which could clear flags after checkIsPaidUser() restored them. The persisted
  flags already hold the last verdict and the online check is authoritative;
  offline expiry enforcement belongs to the deferred self-host migration (#201).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): run entitlement crypto test in node env, not flaky jsdom subtle

jsdom ships only a partial SubtleCrypto (no generateKey), and patching it in
jest.setup was nondeterministic across CI Node builds (passed on one commit,
failed on the next with no setup change). Run verify.test under the node
environment, where crypto.subtle is Node's complete WebCrypto — the verification
logic is WebCrypto-spec behavior, identical between Node and the webview.

Also drop verify.ts's @/logger import so the entitlement module is a pure leaf
(no settings/obsidian graph), which keeps the node-env test import-safe and
matches the module's intended dependency-light design. jest.setup now guards its
window usage so it is a no-op under the node environment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): force WebCrypto onto globalThis, not just window

The prior fix patched window.crypto, but a bare `crypto` reference in a module
resolves to globalThis.crypto, and jest's jsdom environment installs a partial
SubtleCrypto (no generateKey) on globalThis that the window-only patch missed —
so CI kept failing in jsdom while local (clean jsdom) passed. Patch globalThis
(and window) with Node's complete WebCrypto, with a fallback to replacing
`.subtle` if `crypto` is a locked accessor. Reverts the node-env docblock on the
crypto test; the global patch covers the standard jsdom env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): inject Node WebCrypto into entitlement verify instead of patching globals

Root cause of the repeated CI failures: jest's jsdom environment exposes a
partial, locked SubtleCrypto (no generateKey/ECDSA) on the global the module
resolves `crypto` from, and neither replacing window/globalThis.crypto nor the
@jest-environment node docblock reliably overrode it on CI (local jsdom resolves
clean, so it couldn't be reproduced locally).

Fix: give verifyEntitlement an injectable `subtle` option (default
`crypto.subtle`, as used at runtime on desktop + mobile), and have the test pass
Node's webcrypto.subtle explicitly. The test no longer depends on the
environment's global WebCrypto at all, so it's deterministic in any jest env.
jest.setup reverted to its original form (no crypto hacks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): close 3 Codex findings (Lite bypass, log leak, offline expiry)

- P1 (Lite bypass): an unverifiable entitlement token no longer grants the strict
  gate. validateLicenseKey now calls markPaidPendingEntitlement() — paid so general
  Plus features work, but isPlusUser stays false so an unverifiable Lite token
  can't pass the multi-agent gate before the verifying key ships (fails closed).
  Tokenless (old server) still grants paid + Plus.
- P2 (log leak): parseBrevilabsResponse redacts the `entitlement` JWS before
  logInfo, so it never lands in the shared copilot-log.md.
- P2 (offline expiry): persist the token's exp as entitlementExpiresAt and have
  the strict gate (isPlusEnabled / useIsPlusUser) honor it, so multi-agent locks
  at expiry even while /license is unavailable. The broad paid gate keeps legacy
  behavior. ensureMultiAgentEntitlement's slow path re-reads it, so an expired
  token offline is blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* feat(entitlement): embed prod ES256 public key (kid ent-2026-06)

Populate ENTITLEMENT_PUBLIC_KEYS with the production verify-only public
JWK so verifyEntitlement can validate server-signed tokens offline. The
matching private key signs tokens in brevilabs-api.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): require in-session signature proof for strict Plus

Address Codex P1: isPlusEnabled() trusted the persisted isPlusUser
boolean + entitlementExpiresAt without re-verifying the JWS, so an
edited data.json (isPlusUser: true + future expiry) bypassed the
multi-agent gate offline and in the startup window. The signed token was
not actually the gate on cached state.

Gate token-derived strict Plus on an in-memory, non-persisted proof that
a signed entitlement granting multi_agent was cryptographically verified
in THIS process — set by applyEntitlement (server response) or the new
verifyCachedEntitlement (cached token re-checked at startup, offline via
WebCrypto). A persisted boolean alone never sets it, so it fails closed.

The tokenless fallback (pre-token server, entitlementExpiresAt === 0) is
unchanged: no signed artifact exists to verify, so it honors the
license-confirmed flag as today and preserves new-plugin/old-server
compatibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:56:00 -07:00

195 lines
13 KiB
TypeScript

/**
* The Copilot Agent Mode system prompt, shared by every backend.
*
* Why this exists: each agent backend defaults to a generic "CLI software
* engineering tool" framing that is wrong for an Obsidian vault assistant —
* opencode's `default.txt` (its substring picker falls through for Copilot
* Plus model names), codex-acp's built-in prompt, and the Claude Agent SDK's
* `claude_code` preset. Forwarding `COPILOT_PROMPT_BASE` to all three gives
* the same "you are an Obsidian vault assistant" framing everywhere.
*
* `buildAgentSystemPrompt` composes the full payload each backend forwards:
*
* 1. `COPILOT_PROMPT_BASE` (the Obsidian-vault identity) — unless the user
* enabled Settings → System prompts → "Disable builtin system prompt".
* Followed by `COPILOT_PLUS_TOOLS_STEERING` (prefer the builtin Copilot
* Plus skills, with a fallback to the agent's own tools) — sent to everyone.
* Then `COPILOT_MIYO_SEARCH_STEERING` — appended only when `shouldUseMiyo`
* is true, so the agent is pointed at the `miyo-search` skill only while
* Miyo is enabled and available.
* 2. The pill-syntax directive (`buildPillSyntaxDirective`) — always present;
* it teaches the agent how to read the chat editor's `[[note]]`/`{folder}`
* tokens and is functional wiring, not "builtin framing" the user toggles.
* 3. The user's custom prompt (`getEffectiveUserPrompt`) wrapped in
* `<user_custom_instructions>`, mirroring legacy chat's `getSystemPrompt()`.
*
* `COPILOT_PROMPT_BASE` content is curated, not invented. Two existing Copilot
* prompts cover most of what's needed:
*
* - `DEFAULT_SYSTEM_PROMPT` (`src/constants.ts`) — the chat-mode identity and
* formatting rules. Most rules port directly; chat-only hooks (`@vault`,
* `getCurrentTime`, YouTube auto-transcribe) are dropped because that
* infrastructure does not exist in Agent Mode. Reusing `DEFAULT_SYSTEM_PROMPT`
* verbatim would re-introduce that noise.
* - `AGENT_LOOP_GUIDANCE` (`src/LLMProviders/chainRunner/
* AutonomousAgentChainRunner.ts`) — the in-process autonomous agent's loop
* bullets. Ported verbatim — the agent shape is the same.
*/
// Import the pill directive from its module rather than the skills barrel: a
// system-prompt builder needs only this one pure function, not SkillManager,
// discovery, or the Skills UI the barrel also re-exports.
import { buildPillSyntaxDirective } from "@/agentMode/skills/pillSyntaxDirective";
import { shouldUseMiyo } from "@/miyo/miyoUtils";
import { getSettings } from "@/settings/model";
import { getDisableBuiltinSystemPrompt } from "@/system-prompts/state";
import { getEffectiveUserPrompt } from "@/system-prompts/systemPromptBuilder";
/**
* Steers the agent toward the bundled Copilot Plus skills for the relay
* capabilities (see `skills/builtin/builtinSkills.ts`) instead of its own
* built-in web/fetch tools, with an explicit fallback so the request never
* dead-ends. Sent to every user regardless of Plus status: if a skill can't
* run (no license, self-host, disabled, missing), its script exits telling the
* agent to use its own equivalent tools, and the fallback clause routes it
* there — so steering never blocks free users and is safe for everyone.
*/
export const COPILOT_PLUS_TOOLS_STEERING = `## Copilot Plus tools
For these requests, prefer the bundled Copilot skill over any built-in tool of your own:
- Searching the web → the \`copilot-web-search\` skill
- Fetching or reading a specific web page → the \`copilot-web-fetch\` skill
- Reading a PDF file → the \`copilot-read-pdf\` skill
- Getting a YouTube video's transcript → the \`copilot-youtube-transcript\` skill
- Fetching an X (Twitter) post → the \`copilot-fetch-x\` skill
Each skill ships a runnable script per OS — a \`.sh\` for macOS/Linux and a \`.cmd\` for Windows. Follow the skill's own "How to run" instructions for the right command; both runtimes are always present, so nothing needs to be installed.
If a skill is missing, disabled, reports that Copilot Plus is not active, or fails for this particular request (for example a page it can't fetch or any other relay error), silently fall back to your own equivalent tool (or, if you have none for that task, tell the user it's unavailable) and complete the request — never refuse and never block the user on upgrading. Only pass along an upgrade or renewal note when the skill's own message explicitly invites it, and keep any such mention brief and occasional.`;
/**
* Steers the agent toward the bundled `miyo-search` skill for vault search. A
* prose skill is only invoked if the model thinks to use it, and the SKILL.md
* description alone proved unreliable, so we name it explicitly in the system
* prompt the way `COPILOT_PLUS_TOOLS_STEERING` names the relay skills, with
* concrete triggers (grep too slow / too few relevant hits / explicit request).
*
* Unlike the Plus steering, this is gated: it is appended only when
* `shouldUseMiyo(...)` is true, so it never tells the agent to reach for a
* skill that isn't seeded. That keeps the prompt in lockstep with the
* seeding gate in `agentMode/index.ts` (both key off `shouldUseMiyo`), which is
* how the skill respects the user's "Miyo enabled" setting.
*/
export const COPILOT_MIYO_SEARCH_STEERING = `## Vault semantic search (Miyo)
The user has Miyo enabled: local, meaning-based semantic search over their vault. For any vault-search intent, use the \`miyo-search\` skill when your builtin \`grep\` search is too slow or doesn't surface enough relevant notes, or whenever the user explicitly asks for Miyo search. Follow the skill's own instructions to run it.`;
export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant that helps users work with their Obsidian vault — markdown notes for knowledge management, writing, and research. You are NOT a software-engineering agent or CLI coding tool. The working directory is the user's Obsidian vault: a collection of markdown notes, not a code repository. Disregard any framing in environment metadata that suggests otherwise.
## Grounding
- The user's vault contains markdown notes. When the user says "note", they mean an Obsidian note in this vault.
- When the user mentions "tags", they usually mean tags in Obsidian note properties.
- Never claim you do not have access to something. Rely on the user's provided context and the tools available to you.
- If you are unsure, say so and ask for more context — don't guess.
- Always respond in the language of the user's query.
## Tool Behavior
- Prefer evidence from \`read\`, \`grep\`, and \`glob\` over assumption. Don't infer what a note contains from its title — read it.
- NEVER search for the same or very similar query twice. If results were insufficient, try substantially different terms.
- After 1-2 searches, synthesize an answer from the results you have. Do not keep searching unless the results are clearly insufficient.
- If you have enough information to answer, respond directly without calling any more tools.
## Response Style
- Respond at length appropriate to note-taking and knowledge work. Do NOT default to 1-3 line CLI cadence — give the user enough context to understand and act on your answer.
- Be direct and concrete. Don't pad with preamble or postamble.
## Markdown Formatting
- Use \`$...$\` for LaTeX equations, never \`\\[...\\]\` or \`\\(...\\)\`.
- For markdown lists, always use \`- \` (hyphen followed by exactly one space) for bullet points, with no leading spaces. Never use \`*\` for bullets.
- When showing note titles, use the \`[[title]]\` wikilink format and never wrap them in backticks or quotes.
- For Obsidian-internal image links, use the \`![[link]]\` format and never wrap them in backticks.
- For web image links, use the \`![alt](url)\` format and never wrap them in backticks.
- For tables, use valid GitHub-flavored markdown: a header row, then a delimiter row of dashes (e.g. \`| --- | --- |\`), then one row per record — every row wrapped in leading and trailing \`|\`. Put a blank line before the table. If you label the table, put the label on its own line above that blank line; never append a trailing \`|\` to a caption, heading, or any line that is not itself a table row.`;
/**
* Compose the full system prompt every Agent Mode backend forwards. See the
* file header for the three parts and their ordering rationale.
*
* The prompt is provider-agnostic by design: `COPILOT_PROMPT_BASE` establishes
* the Obsidian-vault identity and markdown rules, neither of which varies by
* model family. It is deliberately NOT keyed on the live model — opencode hosts
* BYOK models from many providers in one session and switches between them via
* `setSessionModel` without respawning, so any spawn-time model snapshot would
* be stale the moment the user switched families. If per-family prompt tuning
* is ever needed, key it off the live model at a respawn or per-turn boundary
* (e.g. a `restartOnModelChange` descriptor flag) — not a spawn-time id.
*
* Reads the live system-prompt state (`getDisableBuiltinSystemPrompt`,
* `getEffectiveUserPrompt`) at call time. Backends call this at their natural
* prompt-injection point — spawn time for opencode/codex, `newSession()` for
* the Claude SDK — so a settings change applies to the next session.
*
* `opts.projectInstructions` is the owning project's composed instruction
* body — the built-in project policy layered ahead of the user's `project.md`
* body (an opaque string the caller resolves via
* `getComposedProjectInstructions`; this module never touches the `projects/`
* layer). When absent or blank the output is byte-identical to the no-project
* prompt — the global (no-project) parity guarantee. When present it is
* wrapped in `<project_instructions>` (mirroring `<user_custom_instructions>`)
* as the final section, after the user's custom prompt. codex/opencode get the
* same composed body for free via native `AGENTS.md` discovery from the
* session cwd; this append is the Claude SDK's equivalent, since it has no
* cwd-discovery channel.
*
* Project *file context* (folders/notes/URLs) is NOT part of the system prompt:
* it is delivered as a `<project_context>` block inlined into the session's
* first user message (reachable by all three backends), built by the context
* materializer's `buildProjectContextBlock`.
*/
export function buildAgentSystemPrompt(opts?: { projectInstructions?: string }): string {
const parts: string[] = [];
// The "Disable builtin system prompt" toggle suppresses only the Copilot
// base framing — mirroring how legacy chat's `getSystemPrompt()` drops
// `DEFAULT_SYSTEM_PROMPT`. The pill-syntax directive below is functional
// wiring (it explains the editor's mention tokens), not builtin framing, so
// it is always sent.
if (!getDisableBuiltinSystemPrompt()) {
parts.push(COPILOT_PROMPT_BASE);
// Always steer toward the builtin Copilot Plus skills, regardless of Plus
// status. Gating on `isPaidUser` would be wrong anyway — valid self-host
// mode is Plus-enabled but reports `isPaidUser: false` — and if a skill
// can't run, its script exits telling the agent to use its own equivalent
// tools and the fallback clause routes it there. Never blocks free users.
parts.push(COPILOT_PLUS_TOOLS_STEERING);
// Miyo steering is gated on the same `shouldUseMiyo` check that seeds the
// skill, so we only point the agent at `miyo-search` when it's actually
// available — the prompt-side half of respecting the "Miyo enabled" setting.
if (shouldUseMiyo(getSettings())) {
parts.push(COPILOT_MIYO_SEARCH_STEERING);
}
// Extensibility seam — todo/plan steering. Today `AGENT_TODO_PLANNING_STEERING`
// is injected ONLY in Project scope (via `composeProjectInstructions`), so this
// global prompt stays byte-identical for no-project sessions. To make todo
// planning global: push it here AND remove it from `composeProjectInstructions`
// — otherwise a Project session receives the section twice (once globally, once
// via `<project_instructions>` / AGENTS.md). Note the placement decision: pushing
// it INSIDE this `!getDisableBuiltinSystemPrompt()` branch ties it to the "Disable
// builtin system prompt" toggle (so those users lose Project todo steering too);
// push it OUTSIDE the branch if todo planning should survive that toggle.
}
parts.push(buildPillSyntaxDirective());
const userPrompt = getEffectiveUserPrompt().trim();
if (userPrompt) {
parts.push(`<user_custom_instructions>\n${userPrompt}\n</user_custom_instructions>`);
}
// Optional project-instructions block, last. Absent/blank → nothing pushed,
// so the global (no-project) prompt stays byte-identical. Wrapped in
// `<project_instructions>`, mirroring the `<user_custom_instructions>` block
// above — the plugin's convention for tagging opaque user content.
const projectInstructions = opts?.projectInstructions?.trim();
if (projectInstructions) {
parts.push(`<project_instructions>\n${projectInstructions}\n</project_instructions>`);
}
return parts.join("\n\n");
}