mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* feat(cli): add HAPI_DISABLE_PEER_TOOLS to hide peer-session tools from agents Wrapped agents with peer tools can browse the session list and message other sessions, polluting peer contexts on busy runners. Setting HAPI_DISABLE_PEER_TOOLS=1 (true/yes also accepted) in the runner environment now removes the peer surface entirely instead of advertising it and refusing: - the session MCP server does not register list_peers / inspect_peer / ping_peer, and they are dropped from the toolNames consumed by Claude --allowedTools and the codex/generic bridge --tools lists - the stdio bridge filters peer names even when a stale --tools value lists them explicitly - flavor system prompts (claude/codex/opencode) swap the peer steer block for a short notice that keeps the hub-path-is-not-a-file guard while pointing citations at the user - the peer core (pingPeer/inspectPeer/listPeerSessions) refuses with a new 'disabled' error code before any network call, so shell `hapi ping-peer` / `inspect-peer` from inside a wrapped agent fails loudly as defense in depth Operators scope the variable to the runner (systemd/launchd/guard env); an interactive terminal without it keeps full CLI access. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Code <noreply@anthropic.com> * test(cli): cover peer-tool prompt steer and default bridge tool list Adds regression tests for the two surfaces the kill switch mutates beyond the explicit --tools path: - happyMcpStdioBridge: default tool list (no --tools) keeps the peer tools as a positive control, then HAPI_DISABLE_PEER_TOOLS=1 filters them out of the same default seed. - New cross-flavor prompt test: the citation steer baked into the claude/codex/opencode prompt consts swaps to the disabled notice under the env flag and keeps the flavor-specific "Call \"<tool>\"" instruction by default (module consts evaluate env at import, so each mode is imported fresh via vi.resetModules + dynamic import). via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Code <noreply@anthropic.com> * test(cli): keep peer-tools env set through prompt assertions Keeps HAPI_DISABLE_PEER_TOOLS applied until after the assertions and restores it at test end. Some upstream shapes resolve the citation steer at call time (getSystemPrompt(env)) rather than baking it into the import-time const, so restoring the env inside the import helper made those shapes observe the enabled steer and fail. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: HAPI <noreply@hapi.run> Co-authored-by: Claude Code <noreply@anthropic.com>
170 lines
7.9 KiB
TypeScript
170 lines
7.9 KiB
TypeScript
/**
|
|
* Session citation helpers shared by web Copy-reference, MCP tool descriptions,
|
|
* and flavor system prompts (tiann/hapi#1370).
|
|
*
|
|
* Two paste forms agents must recognize:
|
|
* 1. Copy reference prose: `See session "…" (/sessions/<id>) for context`
|
|
* 2. Markdown composer chips: `[title](/sessions/<id>)`
|
|
*
|
|
* `/sessions/<id>` is a HAPI hub path - not a local filesystem path.
|
|
*/
|
|
|
|
/** Appended to Copy-reference clipboard text so cold pastes steer agents. */
|
|
export const SESSION_REFERENCE_STEER_SUFFIX =
|
|
' HAPI hub peer - call inspect_peer with that session id; do not Grep/Glob/Read /sessions/ as a local file.'
|
|
|
|
/**
|
|
* MCP `inspect_peer` tool description. Written for the model (firing predicate +
|
|
* negative constraint), not for humans.
|
|
*/
|
|
export const INSPECT_PEER_TOOL_DESCRIPTION =
|
|
'Read another HAPI session (metadata + recent message text) on the same hub/namespace. ' +
|
|
'Fires when the user cites a peer via markdown [title](/sessions/<id>), Copy-reference prose ' +
|
|
'See session "…" (/sessions/<id>) for context, or a bare /sessions/<id>. ' +
|
|
'Extract <id> and pass it as sessionIdPrefix. /sessions/<id> is a hub path - do NOT Grep, Glob, or Read it as a local filesystem path. ' +
|
|
'Read-only: does not resume. Prefer this (or `hapi inspect-peer`) over JWT+curl.'
|
|
|
|
/** MCP `ping_peer` tool description (same citation forms as inspect_peer). */
|
|
export const PING_PEER_TOOL_DESCRIPTION =
|
|
'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs on the same hub/namespace. ' +
|
|
'When the user cites a peer via [title](/sessions/<id>), Copy-reference prose See session "…" (/sessions/<id>) for context, or a bare /sessions/<id>, ' +
|
|
'extract <id> and pass it as sessionIdPrefix. /sessions/<id> is a hub path - do NOT search the local filesystem for it. ' +
|
|
'Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat.'
|
|
|
|
/** Zod `.describe` for sessionIdPrefix on inspect_peer / ping_peer. */
|
|
export const SESSION_ID_PREFIX_PARAM_DESCRIPTION =
|
|
'Target HAPI session id or unique id prefix (another session - not this chat). ' +
|
|
'Prefer the full UUID from [title](/sessions/<id>) or Copy-reference See session "…" (/sessions/<id>) for context.'
|
|
|
|
/**
|
|
* Hub session ids have no dots. Reject dotted tails so source paths like
|
|
* `web/src/routes/sessions/chat.tsx` are not treated as citations.
|
|
*/
|
|
function isPlausibleSessionId(id: string): boolean {
|
|
return id.length > 0 && !id.includes('.')
|
|
}
|
|
|
|
/**
|
|
* Match `/sessions/<id>` (optional BASE_URL prefix segments) inside free text.
|
|
* Captures the id only; surrounding markdown / prose is ignored.
|
|
*/
|
|
const SESSION_PATH_IN_TEXT_RE =
|
|
/(?:^|[^A-Za-z0-9_-])(?:\.?\/)?(?:[\w.-]+\/)*sessions\/([^/?#\s)\]"']+)/g
|
|
|
|
/** Decode a path segment; return null if not a plausible hub session id. */
|
|
function decodeSessionIdSegment(raw: string): string | null {
|
|
try {
|
|
// Bare citations in prose often trail `,` / `.` / `!` - strip those only.
|
|
// Internal dots (e.g. `chat.tsx`) stay and fail isPlausibleSessionId.
|
|
const id = decodeURIComponent(raw).replace(/[.,;:!?]+$/u, '')
|
|
return isPlausibleSessionId(id) ? id : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract unique session ids from free text containing markdown and/or
|
|
* Copy-reference prose citations. Order is first-seen.
|
|
*/
|
|
export function extractSessionCitationIds(text: string): string[] {
|
|
if (!text) return []
|
|
const seen = new Set<string>()
|
|
const ids: string[] = []
|
|
SESSION_PATH_IN_TEXT_RE.lastIndex = 0
|
|
let match: RegExpExecArray | null
|
|
while ((match = SESSION_PATH_IN_TEXT_RE.exec(text)) !== null) {
|
|
const id = decodeSessionIdSegment(match[1] ?? '')
|
|
if (!id || seen.has(id)) continue
|
|
seen.add(id)
|
|
ids.push(id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
/**
|
|
* True when `match` consumed the whole citation paste (optional steer suffix only).
|
|
* Prevents `See session "A" (/sessions/a) for context and [B](/sessions/b)` from
|
|
* short-circuiting to `a` and skipping the multi-id fail-closed path.
|
|
*/
|
|
function hasCanonicalCopyTail(match: RegExpExecArray, input: string): boolean {
|
|
const tail = input.slice(match[0].length)
|
|
return tail === '' || tail === `.${SESSION_REFERENCE_STEER_SUFFIX}`
|
|
}
|
|
|
|
/**
|
|
* If `raw` is a pasted citation blob containing exactly one `/sessions/<id>`,
|
|
* return that id. Bare prefixes/ids (no `/sessions/`) pass through trimmed.
|
|
* Ambiguous or empty citation blobs fail closed as `""` so callers refuse
|
|
* rather than silently picking the first peer (inspect + ping share this path).
|
|
*
|
|
* Copy-reference prose prefers the parenthesized path so a session title that
|
|
* itself contains `/sessions/<other>` cannot shadow the real target - but only
|
|
* when the paste is a canonical Copy-reference (optional steer suffix), not a
|
|
* multi-citation blob.
|
|
*/
|
|
export function normalizeSessionIdPrefix(raw: string): string {
|
|
const trimmed = raw.trim()
|
|
|
|
const titledCopy = /^See session "(?:\\.|[^"\\])*" \(([^)]+)\) for context/.exec(trimmed)
|
|
if (titledCopy?.[1] && hasCanonicalCopyTail(titledCopy, trimmed)) {
|
|
const ids = extractSessionCitationIds(titledCopy[1])
|
|
return ids.length === 1 ? ids[0]! : ''
|
|
}
|
|
const untitledCopy = /^See HAPI session (\S+) for context/.exec(trimmed)
|
|
if (untitledCopy?.[1] && hasCanonicalCopyTail(untitledCopy, trimmed)) {
|
|
const ids = extractSessionCitationIds(untitledCopy[1])
|
|
return ids.length === 1 ? ids[0]! : ''
|
|
}
|
|
|
|
if (!trimmed.includes('/sessions/')) {
|
|
return trimmed
|
|
}
|
|
const ids = extractSessionCitationIds(trimmed)
|
|
return ids.length === 1 ? ids[0]! : ''
|
|
}
|
|
|
|
export type SessionCitationSteerTools = {
|
|
/** Flavor-specific inspect tool name, e.g. `mcp__hapi__inspect_peer`. */
|
|
inspectTool: string
|
|
/** Flavor-specific ping tool name, e.g. `mcp__hapi__ping_peer`. */
|
|
pingTool: string
|
|
/** Flavor-specific discovery tool when no citation is available. */
|
|
listPeersTool?: string
|
|
}
|
|
|
|
/**
|
|
* System-prompt citation block used when peer tools are hidden by
|
|
* `HAPI_DISABLE_PEER_TOOLS`. Keeps the hub-path-is-not-a-file guard (pasted
|
|
* citations still arrive) but points the agent at the user instead of the
|
|
* disabled peer tools.
|
|
*/
|
|
export const SESSION_CITATION_DISABLED_STEER =
|
|
'When the user cites another HAPI session as [title](/sessions/<id>), ' +
|
|
'Copy-reference prose See session "…" (/sessions/<id>) for context, ' +
|
|
'or a bare /sessions/<id>, extract that <id>. ' +
|
|
'/sessions/<id> is a HAPI hub path, not a local filesystem path - do not Grep, Glob, or Read it as a file. ' +
|
|
'Peer session tools (inspect_peer / ping_peer / list_peers) are disabled on this machine; ' +
|
|
'ask the user to paste the relevant context from that session instead.'
|
|
|
|
/**
|
|
* Always-on system-prompt line: both citation forms + hub-not-FS + tool names.
|
|
*/
|
|
export function buildSessionCitationSteerInstruction(tools: SessionCitationSteerTools): string {
|
|
let text =
|
|
`When the user cites another HAPI session as [title](/sessions/<id>), ` +
|
|
`Copy-reference prose See session "…" (/sessions/<id>) for context, ` +
|
|
`or a bare /sessions/<id>, extract that <id>. ` +
|
|
`/sessions/<id> is a HAPI hub path, not a local filesystem path - do not Grep, Glob, or Read it as a file. ` +
|
|
`Call "${tools.inspectTool}" with sessionIdPrefix=<id> to read metadata and recent messages; ` +
|
|
`call "${tools.pingTool}" with sessionIdPrefix=<id> and a message to nudge or hand off. ` +
|
|
`Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer <id> / hapi ping-peer <id> <message>.`
|
|
if (tools.listPeersTool) {
|
|
text +=
|
|
` To discover peers without a citation, call "${tools.listPeersTool}" ` +
|
|
`(same hub/namespace; works from runner-spawned sessions). ` +
|
|
`Shell fallback: hapi ping-peer --list.`
|
|
}
|
|
return text
|
|
}
|