From 2613bd6618a178efcc48120b6584de2740e52995 Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:47:49 +0800 Subject: [PATCH 01/14] 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 Co-Authored-By: Claude Code --- cli/src/claude/utils/startHappyServer.test.ts | 53 ++++ cli/src/claude/utils/startHappyServer.ts | 250 +++++++++--------- cli/src/claude/utils/systemPrompt.ts | 5 +- cli/src/codex/happyMcpStdioBridge.test.ts | 18 +- cli/src/codex/happyMcpStdioBridge.ts | 8 + cli/src/codex/utils/buildHapiMcpBridge.ts | 10 +- cli/src/codex/utils/systemPrompt.ts | 5 +- cli/src/modules/common/peerTools.test.ts | 34 +++ cli/src/modules/common/peerTools.ts | 19 ++ cli/src/modules/pingPeer/pingPeer.test.ts | 39 ++- cli/src/modules/pingPeer/pingPeer.ts | 20 ++ cli/src/opencode/utils/systemPrompt.ts | 7 +- docs/guide/how-it-works.md | 2 +- docs/guide/installation.md | 4 + shared/src/sessionCitation.ts | 14 + 15 files changed, 356 insertions(+), 132 deletions(-) create mode 100644 cli/src/modules/common/peerTools.test.ts create mode 100644 cli/src/modules/common/peerTools.ts diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 8abb273f25..fda93291ba 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -328,3 +328,56 @@ describe('toClaudeAllowedHapiMcpTools', () => { expect(toClaudeAllowedHapiMcpTools(['display_media'])).not.toContain('mcp__hapi__display_media') }) }) + +describe('startHappyServer peer tools kill switch', () => { + const originalFlag = process.env.HAPI_DISABLE_PEER_TOOLS + let client: Client | null + let stopServer: (() => void) | null + + afterEach(async () => { + await client?.close() + stopServer?.() + if (originalFlag === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = originalFlag + } + }) + + async function startServer(): Promise<{ url: string; toolNames: string[] }> { + const sessionClient = { + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + sendClaudeSessionMessage: vi.fn() + } as unknown as ApiSessionClient + const server = await startHappyServer(sessionClient, {}) + stopServer = server.stop + client = new Client( + { name: 'hapi-peer-tools-test', version: '1.0.0' }, + { capabilities: {} } + ) + await client.connect(new StreamableHTTPClientTransport(new URL(server.url))) + return { url: server.url, toolNames: server.toolNames } + } + + it('registers peer tools by default', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + const { toolNames } = await startServer() + expect(toolNames).toEqual(expect.arrayContaining(['list_peers', 'ping_peer', 'inspect_peer'])) + const tools = await client!.listTools() + const names = tools.tools.map((tool) => tool.name) + expect(names).toEqual(expect.arrayContaining(['list_peers', 'ping_peer', 'inspect_peer'])) + }) + + it('omits peer tools from registration and toolNames when HAPI_DISABLE_PEER_TOOLS is set', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + const { toolNames } = await startServer() + expect(toolNames).toEqual(['change_title', 'display_image', 'display_video', 'display_media']) + const tools = await client!.listTools() + const names = tools.tools.map((tool) => tool.name) + expect(names).not.toContain('list_peers') + expect(names).not.toContain('ping_peer') + expect(names).not.toContain('inspect_peer') + expect(names).toEqual(expect.arrayContaining(['display_image', 'display_video', 'display_media'])) + }) +}) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 10a2a02d66..e3814f776d 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -21,6 +21,7 @@ import { import type { InlineMediaSource } from "@/modules/common/inlineMediaSource"; import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from "@/modules/common/displayImagePrompt"; import { resolveSkill } from "@/modules/common/skills"; +import { isPeerToolsDisabled } from "@/modules/common/peerTools"; import { INSPECT_PEER_TOOL_DESCRIPTION, PING_PEER_TOOL_DESCRIPTION, @@ -121,6 +122,10 @@ function createHapiMcpServer( const maxInlineMediaBytes = 25 * 1024 * 1024; + // HAPI_DISABLE_PEER_TOOLS=1: peer tools are neither registered nor + // advertised, so wrapped agents cannot browse or message other sessions. + const peerToolsDisabled = isPeerToolsDisabled(); + const inspectPeerInputSchema: z.ZodTypeAny = z.object({ sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION), messageLimit: z.number().int().min(1).max(100).optional().describe( @@ -298,127 +303,129 @@ function createHapiMcpServer( } }); - mcp.registerTool('ping_peer', { - description: PING_PEER_TOOL_DESCRIPTION, - title: 'Ping Peer Session', - inputSchema: pingPeerInputSchema, - }, async (args: { sessionIdPrefix: string; message: string }) => { - logger.debug('[hapiMCP] ping_peer:', args.sessionIdPrefix); - try { - const result = await pingPeer({ - sessionIdPrefix: args.sessionIdPrefix, - message: args.message, - }); - return { - content: [ - { - type: 'text' as const, - text: `Delivered to ${result.sessionId}${result.resumed ? ' (resumed)' : ''} (${result.name})`, - }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof PingPeerError - ? error.message - : error instanceof Error + if (!peerToolsDisabled) { + mcp.registerTool('ping_peer', { + description: PING_PEER_TOOL_DESCRIPTION, + title: 'Ping Peer Session', + inputSchema: pingPeerInputSchema, + }, async (args: { sessionIdPrefix: string; message: string }) => { + logger.debug('[hapiMCP] ping_peer:', args.sessionIdPrefix); + try { + const result = await pingPeer({ + sessionIdPrefix: args.sessionIdPrefix, + message: args.message, + }); + return { + content: [ + { + type: 'text' as const, + text: `Delivered to ${result.sessionId}${result.resumed ? ' (resumed)' : ''} (${result.name})`, + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof PingPeerError ? error.message - : String(error); - logger.debug('[hapiMCP] ping_peer failed:', message); - return { - content: [ - { - type: 'text' as const, - text: `Failed to ping peer: ${message}`, - }, - ], - isError: true, - }; - } - }); + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] ping_peer failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to ping peer: ${message}`, + }, + ], + isError: true, + }; + } + }); - mcp.registerTool('inspect_peer', { - description: INSPECT_PEER_TOOL_DESCRIPTION, - title: 'Inspect Peer Session', - inputSchema: inspectPeerInputSchema, - }, async (args: { sessionIdPrefix: string; messageLimit?: number }) => { - logger.debug('[hapiMCP] inspect_peer:', args.sessionIdPrefix); - try { - const result = await inspectPeer({ - sessionIdPrefix: args.sessionIdPrefix, - messageLimit: args.messageLimit, - }); - return { - content: [ - { - type: 'text' as const, - text: formatInspectPeerReport(result), - }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof PingPeerError - ? error.message - : error instanceof Error + mcp.registerTool('inspect_peer', { + description: INSPECT_PEER_TOOL_DESCRIPTION, + title: 'Inspect Peer Session', + inputSchema: inspectPeerInputSchema, + }, async (args: { sessionIdPrefix: string; messageLimit?: number }) => { + logger.debug('[hapiMCP] inspect_peer:', args.sessionIdPrefix); + try { + const result = await inspectPeer({ + sessionIdPrefix: args.sessionIdPrefix, + messageLimit: args.messageLimit, + }); + return { + content: [ + { + type: 'text' as const, + text: formatInspectPeerReport(result), + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof PingPeerError ? error.message - : String(error); - logger.debug('[hapiMCP] inspect_peer failed:', message); - return { - content: [ - { - type: 'text' as const, - text: `Failed to inspect peer: ${message}`, - }, - ], - isError: true, - }; - } - }); + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] inspect_peer failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to inspect peer: ${message}`, + }, + ], + isError: true, + }; + } + }); - mcp.registerTool('list_peers', { - description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.', - title: 'List Peer Sessions', - inputSchema: listPeersInputSchema, - }, async (args: { limit?: number }) => { - logger.debug('[hapiMCP] list_peers'); - try { - const limit = args.limit ?? 30; - const sessions = await listPeerSessions({ - limit: peerListFetchLimit(limit, { excludeCaller: true }), - }); - const peers = sessions.filter((session) => session.id !== client.sessionId); - const hasMore = peers.length > limit; - return { - content: [ - { - type: 'text' as const, - text: formatPeerSessionsList(peers, { - maxRows: limit, - hasMore, - }), - }, - ], - isError: false, - }; - } catch (error) { - const message = error instanceof PingPeerError - ? error.message - : error instanceof Error + mcp.registerTool('list_peers', { + description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.', + title: 'List Peer Sessions', + inputSchema: listPeersInputSchema, + }, async (args: { limit?: number }) => { + logger.debug('[hapiMCP] list_peers'); + try { + const limit = args.limit ?? 30; + const sessions = await listPeerSessions({ + limit: peerListFetchLimit(limit, { excludeCaller: true }), + }); + const peers = sessions.filter((session) => session.id !== client.sessionId); + const hasMore = peers.length > limit; + return { + content: [ + { + type: 'text' as const, + text: formatPeerSessionsList(peers, { + maxRows: limit, + hasMore, + }), + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof PingPeerError ? error.message - : String(error); - logger.debug('[hapiMCP] list_peers failed:', message); - return { - content: [ - { - type: 'text' as const, - text: `Failed to list peers: ${message}`, - }, - ], - isError: true, - }; - } - }); + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] list_peers failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to list peers: ${message}`, + }, + ], + isError: true, + }; + } + }); + } if (skillLookup) { @@ -540,9 +547,14 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH hapiMcpUrl: mcpUrl, })); - const toolNames = enableChangeTitle - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'] - : ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; + const peerToolsDisabled = isPeerToolsDisabled(); + const toolNames = [ + ...(enableChangeTitle ? ['change_title'] : []), + 'display_image', + 'display_video', + 'display_media', + ...(peerToolsDisabled ? [] : (['list_peers', 'ping_peer', 'inspect_peer'] as const)), + ]; if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 3174ac6edd..1ed7241eec 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,5 +1,6 @@ import { trimIdent } from "@/utils/trimIdent"; -import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCitation"; +import { buildSessionCitationSteerInstruction, SESSION_CITATION_DISABLED_STEER } from "@hapi/protocol/sessionCitation"; +import { isPeerToolsDisabled } from "@/modules/common/peerTools"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; @@ -12,7 +13,7 @@ const BASE_SYSTEM_PROMPT = (() => trimIdent(` ${DISPLAY_IMAGE_PROMPT_CLAUDE} ${DISPLAY_VIDEO_PROMPT_CLAUDE} ${DISPLAY_MEDIA_PROMPT_CLAUDE} - ${buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'mcp__hapi__inspect_peer', pingTool: 'mcp__hapi__ping_peer', listPeersTool: 'mcp__hapi__list_peers', diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index c940d0bc50..fd58039557 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -176,4 +176,20 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { ]) }) -}) + + it('hides peer tools under HAPI_DISABLE_PEER_TOOLS even when listed in --tools', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + try { + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + 'change_title,list_peers,ping_peer,inspect_peer' + ]) + + expect([...harness.tools.keys()]).toEqual(['change_title']) + } finally { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } + }) +}) \ No newline at end of file diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index e1bcd979c8..80e2c07c87 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -22,6 +22,7 @@ import { PING_PEER_TOOL_DESCRIPTION, SESSION_ID_PREFIX_PARAM_DESCRIPTION, } from '@hapi/protocol/sessionCitation'; +import { isPeerToolsDisabled, PEER_TOOL_NAMES } from '@/modules/common/peerTools'; const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; @@ -50,6 +51,13 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { try { // Resolve target HTTP MCP URL const { url: urlFromArgs, toolNames } = parseArgs(argv); + + // HAPI_DISABLE_PEER_TOOLS=1: drop peer tools even when explicitly listed, + // so a wrapped agent cannot re-enable them via a stale --tools value. + if (isPeerToolsDisabled()) { + for (const name of PEER_TOOL_NAMES) toolNames.delete(name); + } + const baseUrl = urlFromArgs || process.env.HAPI_HTTP_MCP_URL || ''; if (!baseUrl) { diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 8091db55cc..712f48b89e 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -9,6 +9,7 @@ import { startHappyServer } from '@/claude/utils/startHappyServer'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import type { ApiSessionClient } from '@/api/apiSession'; import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv'; +import { isPeerToolsDisabled } from '@/modules/common/peerTools'; import type { CodexMcpServerConfig } from './codexMcpServers'; /** @@ -112,9 +113,12 @@ export async function buildHapiMcpBridge( }; } // Discovery shortlist only - same trust as skill_lookup / change_title. - tools.list_peers = { - approval_mode: 'approve' - }; + // Skipped under HAPI_DISABLE_PEER_TOOLS (tool is not registered then). + if (!isPeerToolsDisabled()) { + tools.list_peers = { + approval_mode: 'approve' + }; + } // ping_peer / inspect_peer are registered on the HTTP MCP server / stdio // bridge, but are not auto-approved: they target another session (resume + // inject, or read peer histories). diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index bd85efc36c..71d74583f0 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -6,8 +6,9 @@ */ import { trimIdent } from '@/utils/trimIdent'; -import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; +import { buildSessionCitationSteerInstruction, SESSION_CITATION_DISABLED_STEER } from '@hapi/protocol/sessionCitation'; import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; +import { isPeerToolsDisabled } from '@/modules/common/peerTools'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** @@ -24,7 +25,7 @@ export const TITLE_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_CODEX} ${DISPLAY_VIDEO_PROMPT_CODEX} ${DISPLAY_MEDIA_PROMPT_CODEX} - ${buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'functions.hapi__inspect_peer', pingTool: 'functions.hapi__ping_peer', listPeersTool: 'functions.hapi__list_peers', diff --git a/cli/src/modules/common/peerTools.test.ts b/cli/src/modules/common/peerTools.test.ts new file mode 100644 index 0000000000..ed4fea3798 --- /dev/null +++ b/cli/src/modules/common/peerTools.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { isPeerToolsDisabled } from './peerTools' + +describe('isPeerToolsDisabled', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original + } + }) + + it('defaults to enabled (peer tools visible)', () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + expect(isPeerToolsDisabled()).toBe(false) + }) + + it.each(['1', 'true', 'TRUE', 'yes', 'Yes'])('disables peer tools for %s', (value) => { + process.env.HAPI_DISABLE_PEER_TOOLS = value + expect(isPeerToolsDisabled()).toBe(true) + }) + + it('stays enabled for unrelated values', () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '0' + expect(isPeerToolsDisabled()).toBe(false) + }) + + it('reads from an explicit env object', () => { + expect(isPeerToolsDisabled({ HAPI_DISABLE_PEER_TOOLS: '1' } as NodeJS.ProcessEnv)).toBe(true) + expect(isPeerToolsDisabled({} as NodeJS.ProcessEnv)).toBe(false) + }) +}) diff --git a/cli/src/modules/common/peerTools.ts b/cli/src/modules/common/peerTools.ts new file mode 100644 index 0000000000..18c1588860 --- /dev/null +++ b/cli/src/modules/common/peerTools.ts @@ -0,0 +1,19 @@ +/** + * Kill switch for the peer-session MCP tools (`list_peers` / `inspect_peer` / + * `ping_peer`). + * + * Set `HAPI_DISABLE_PEER_TOOLS=1` in the runner environment to stop wrapped + * agents from browsing or messaging other HAPI sessions (peer pollution): + * the tools are not registered on the session MCP server, not advertised via + * `--allowedTools` / `--tools`, and the peer steer block is dropped from + * flavor system prompts. The core module refuses as defense in depth, so + * `hapi ping-peer` / `inspect-peer` from inside a wrapped agent shell fails + * loudly instead of silently bypassing the switch. Operators who keep the + * variable unset in their interactive shell keep full CLI access. + */ + +export const PEER_TOOL_NAMES = ['list_peers', 'ping_peer', 'inspect_peer'] as const; + +export function isPeerToolsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return ['true', '1', 'yes'].includes((env.HAPI_DISABLE_PEER_TOOLS ?? '').toLowerCase()); +} diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts index a442b0cddb..8c5e2ff3a1 100644 --- a/cli/src/modules/pingPeer/pingPeer.test.ts +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -1,7 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PingPeerError, exitCodeForPingPeerError, + inspectPeer, + listPeerSessions, pingPeer, resolveSessionByPrefix, type PingPeerSessionSummary @@ -672,3 +674,38 @@ describe('listSessions query params', () => { expect(pingParams[0]).toBeUndefined() }) }) + + +describe('HAPI_DISABLE_PEER_TOOLS kill switch', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS + + beforeEach(() => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + }) + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original + } + }) + + it('pingPeer refuses before any network call', async () => { + await expect(pingPeer({ sessionIdPrefix: 'abc', message: 'hi' })) + .rejects.toMatchObject({ code: 'disabled' }) + }) + + it('inspectPeer refuses before any network call', async () => { + await expect(inspectPeer({ sessionIdPrefix: 'abc' })) + .rejects.toMatchObject({ code: 'disabled' }) + }) + + it('listPeerSessions refuses before any network call', async () => { + await expect(listPeerSessions()).rejects.toMatchObject({ code: 'disabled' }) + }) + + it('maps the disabled code to the usage exit code', () => { + expect(exitCodeForPingPeerError(new PingPeerError('disabled', 'disabled on this machine'))).toBe(2) + }) +}) diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index 1a554d917e..dfd2d09e89 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -12,11 +12,13 @@ import axios, { type AxiosInstance } from 'axios' import { extractAssistantPlainText, isObject } from '@hapi/protocol' import { normalizeSessionIdPrefix } from '@hapi/protocol/sessionCitation' import { configuration } from '@/configuration' +import { isPeerToolsDisabled } from '@/modules/common/peerTools' import { getAuthToken } from '@/api/auth' import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' export type PingPeerErrorCode = | 'bad_args' + | 'disabled' | 'auth_failed' | 'not_found' | 'ambiguous' @@ -34,6 +36,20 @@ export class PingPeerError extends Error { } } +/** + * Defense-in-depth gate for HAPI_DISABLE_PEER_TOOLS: the MCP server and CLI + * surface refuse earlier, but any other caller (stale --tools bridge, shell + * `hapi ping-peer` from a wrapped agent) lands here and fails loudly. + */ +function assertPeerToolsEnabled(): void { + if (isPeerToolsDisabled()) { + throw new PingPeerError( + 'disabled', + 'peer session tools are disabled on this machine (HAPI_DISABLE_PEER_TOOLS is set)' + ) + } +} + export type PingPeerSessionSummary = { id: string active: boolean @@ -370,6 +386,7 @@ async function sendMessage( export async function listPeerSessions( options: ListPeerSessionsOptions = {} ): Promise { + assertPeerToolsEnabled() const apiUrl = resolveApiUrl(options.apiUrl) const accessToken = resolveAccessToken(options.accessToken) const http = options.http ?? axios @@ -456,6 +473,7 @@ export function formatPeerSessionsList( } export async function pingPeer(options: PingPeerOptions): Promise { + assertPeerToolsEnabled() const prefix = normalizeSessionIdPrefix(options.sessionIdPrefix ?? '') const message = options.message ?? '' if (!prefix) { @@ -528,6 +546,7 @@ export async function pingPeer(options: PingPeerOptions): Promise { + assertPeerToolsEnabled() const prefix = normalizeSessionIdPrefix(options.sessionIdPrefix ?? '') if (!prefix) { throw new PingPeerError('bad_args', 'session id prefix is required') diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index b1838d33e7..6b805d990e 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -6,7 +6,8 @@ */ import { trimIdent } from '@/utils/trimIdent'; -import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; +import { buildSessionCitationSteerInstruction, SESSION_CITATION_DISABLED_STEER } from '@hapi/protocol/sessionCitation'; +import { isPeerToolsDisabled } from '@/modules/common/peerTools'; import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; import { DISPLAY_IMAGE_PROMPT_HAPI_MCP, @@ -21,7 +22,7 @@ import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryIn */ export const TITLE_INSTRUCTION = trimIdent(` ${HAPI_MCP_BRIDGE_PROMPT} - ${buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', listPeersTool: 'hapi_list_peers', @@ -41,7 +42,7 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} ${DISPLAY_MEDIA_PROMPT_HAPI_MCP} - ${buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', listPeersTool: 'hapi_list_peers', diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index 210036b7cc..06e1d39508 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -36,7 +36,7 @@ hapi runner start # Run background service for remote session spawning hapi ping-peer --list # Shell peer shortlist (prefer MCP list_peers in-session) ``` -MCP peer tools (same hub/namespace as the session): `list_peers` (discover), `inspect_peer` (read), `ping_peer` (message). These work from runner-spawned sessions even when the hub is on another host - see [Installation → Split hub + remote runner](./installation.md#split-hub-remote-runner-peer-discovery). +MCP peer tools (same hub/namespace as the session): `list_peers` (discover), `inspect_peer` (read), `ping_peer` (message). These work from runner-spawned sessions even when the hub is on another host - see [Installation → Split hub + remote runner](./installation.md#split-hub-remote-runner-peer-discovery). Runners that must keep agents out of other sessions' contexts can hide all three with `HAPI_DISABLE_PEER_TOOLS=1` - see [Installation → Disabling peer tools](./installation.md#disabling-peer-tools-on-a-runner). ### HAPI Hub diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 70c36e5fec..e9d75f3bec 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -320,6 +320,10 @@ hapi runner start Session CLI may export an **explicit** non-default `HAPI_API_URL` (from env or settings) into child env so shell helpers hit the same remote hub. It does **not** mirror `CLI_API_TOKEN` into wrapped agents (settings/prompt-backed secrets stay out of agent env; a fresh `hapi` re-reads `~/.hapi/settings.json`, and systemd/env tokens already inherit). Prefer MCP `list_peers` inside a session. Web terminal PTYs still strip hub secrets. If `--list` fails with an auth/URL error, the message points at `hapi auth login` and the configured hub URL. +##### Disabling peer tools on a runner + +Set `HAPI_DISABLE_PEER_TOOLS=1` (also `true`/`yes`) in the runner environment to stop wrapped agents from browsing or messaging other HAPI sessions — for example when one agent digging through the session list and pinging peers pollutes other sessions' contexts. The switch is not advertised-then-refused: the `list_peers` / `inspect_peer` / `ping_peer` MCP tools are not registered on the session MCP server and not passed to `--allowedTools` / `--tools`, the peer steer block is dropped from agent system prompts (a pasted `/sessions/` citation only reminds the agent it is a hub path, not a local file), and the peer core refuses with an explicit `disabled` error as defense in depth — so `hapi ping-peer` / `hapi inspect-peer` from inside a wrapped agent shell fails loudly too. Scope the env to the runner (systemd unit, launchd plist, or runner guard script); operators who leave it unset in their interactive terminal keep full `hapi ping-peer` CLI access. + Additional runner commands: ```bash diff --git a/shared/src/sessionCitation.ts b/shared/src/sessionCitation.ts index bca4efe5e1..e13e0c3994 100644 --- a/shared/src/sessionCitation.ts +++ b/shared/src/sessionCitation.ts @@ -133,6 +133,20 @@ export type SessionCitationSteerTools = { 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/), ' + + 'Copy-reference prose See session "…" (/sessions/) for context, ' + + 'or a bare /sessions/, extract that . ' + + '/sessions/ 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. */ From cf87c8355eabac682521b1c4a6686aa090edbc8c Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:30:42 +0800 Subject: [PATCH 02/14] 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 \"\"" 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 Co-Authored-By: Claude Code --- .../utils/systemPrompt.peerTools.test.ts | 55 +++++++++++++++++++ cli/src/codex/happyMcpStdioBridge.test.ts | 34 ++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 cli/src/claude/utils/systemPrompt.peerTools.test.ts diff --git a/cli/src/claude/utils/systemPrompt.peerTools.test.ts b/cli/src/claude/utils/systemPrompt.peerTools.test.ts new file mode 100644 index 0000000000..34ed44f771 --- /dev/null +++ b/cli/src/claude/utils/systemPrompt.peerTools.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' + +/** + * Peer-tools citation steer across all three flavors. + * + * The enabled/disabled citation block is baked into module-level prompt consts + * at import time (isPeerToolsDisabled() reads process.env during const + * evaluation), so each mode needs a fresh module evaluation via + * vi.resetModules() + dynamic import with the env set beforehand. + */ +async function importFlavorPrompts(disabled: boolean) { + vi.resetModules() + const prev = process.env.HAPI_DISABLE_PEER_TOOLS + if (disabled) process.env.HAPI_DISABLE_PEER_TOOLS = '1' + else delete process.env.HAPI_DISABLE_PEER_TOOLS + try { + const [claude, codex, opencode] = await Promise.all([ + import('./systemPrompt'), + import('../../codex/utils/systemPrompt'), + import('../../opencode/utils/systemPrompt'), + ]) + return { claude, codex, opencode } + } finally { + if (prev === undefined) delete process.env.HAPI_DISABLE_PEER_TOOLS + else process.env.HAPI_DISABLE_PEER_TOOLS = prev + } +} + +describe('peer-tools citation steer across flavors', () => { + it('replaces the citation tool steer with the disabled notice when HAPI_DISABLE_PEER_TOOLS is set', async () => { + const { claude, codex, opencode } = await importFlavorPrompts(true) + + const claudePrompt = claude.getSystemPrompt() + expect(claudePrompt).toContain('are disabled on this machine') + expect(claudePrompt).not.toContain('Call "mcp__hapi__inspect_peer"') + + expect(codex.TITLE_INSTRUCTION).toContain('are disabled on this machine') + expect(codex.TITLE_INSTRUCTION).not.toContain('Call "functions.hapi__inspect_peer"') + + expect(opencode.TITLE_INSTRUCTION).toContain('are disabled on this machine') + expect(opencode.TITLE_INSTRUCTION).not.toContain('Call "hapi_inspect_peer"') + + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).toContain('are disabled on this machine') + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).not.toContain('Call "hapi_inspect_peer"') + }) + + it('keeps the citation tool steer by default', async () => { + const { claude, codex, opencode } = await importFlavorPrompts(false) + + expect(claude.getSystemPrompt()).toContain('Call "mcp__hapi__inspect_peer"') + expect(codex.TITLE_INSTRUCTION).toContain('Call "functions.hapi__inspect_peer"') + expect(opencode.TITLE_INSTRUCTION).toContain('Call "hapi_inspect_peer"') + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).toContain('Call "hapi_inspect_peer"') + }) +}) diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index fd58039557..628ec4dc2b 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -192,4 +192,38 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { delete process.env.HAPI_DISABLE_PEER_TOOLS } }) + + it('drops peer tools from the default tool list under HAPI_DISABLE_PEER_TOOLS', async () => { + // No --tools: the bridge seeds its default list, which includes the + // peer tools. Positive control first so the disabled run below proves + // filtering rather than an empty seed. + await runHappyMcpStdioBridge(['--url', 'http://127.0.0.1:43006']) + + expect([...harness.tools.keys()]).toEqual([ + 'change_title', + 'display_image', + 'display_video', + 'display_media', + 'ping_peer', + 'inspect_peer', + 'list_peers' + ]) + + harness.tools.clear() + harness.configs.clear() + + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + try { + await runHappyMcpStdioBridge(['--url', 'http://127.0.0.1:43006']) + + expect([...harness.tools.keys()]).toEqual([ + 'change_title', + 'display_image', + 'display_video', + 'display_media' + ]) + } finally { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } + }) }) \ No newline at end of file From f40f0084819ad10d775b6566c29240357e730f8b Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:32:06 +0800 Subject: [PATCH 03/14] 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 Co-Authored-By: Claude Code --- .../utils/systemPrompt.peerTools.test.ts | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/cli/src/claude/utils/systemPrompt.peerTools.test.ts b/cli/src/claude/utils/systemPrompt.peerTools.test.ts index 34ed44f771..a7027c6c89 100644 --- a/cli/src/claude/utils/systemPrompt.peerTools.test.ts +++ b/cli/src/claude/utils/systemPrompt.peerTools.test.ts @@ -3,53 +3,59 @@ import { describe, expect, it, vi } from 'vitest' /** * Peer-tools citation steer across all three flavors. * - * The enabled/disabled citation block is baked into module-level prompt consts - * at import time (isPeerToolsDisabled() reads process.env during const - * evaluation), so each mode needs a fresh module evaluation via - * vi.resetModules() + dynamic import with the env set beforehand. + * The gate is resolved either at import time (citation block baked into + * module-level prompt consts, feature shape) or at call time + * (getSystemPrompt(env) resolving per call, stack shape). Setting the env + * before import AND keeping it set through the assertions covers both + * shapes; each mode re-imports the modules fresh via vi.resetModules(). */ async function importFlavorPrompts(disabled: boolean) { vi.resetModules() const prev = process.env.HAPI_DISABLE_PEER_TOOLS if (disabled) process.env.HAPI_DISABLE_PEER_TOOLS = '1' else delete process.env.HAPI_DISABLE_PEER_TOOLS - try { - const [claude, codex, opencode] = await Promise.all([ - import('./systemPrompt'), - import('../../codex/utils/systemPrompt'), - import('../../opencode/utils/systemPrompt'), - ]) - return { claude, codex, opencode } - } finally { + const [claude, codex, opencode] = await Promise.all([ + import('./systemPrompt'), + import('../../codex/utils/systemPrompt'), + import('../../opencode/utils/systemPrompt'), + ]) + const restore = () => { if (prev === undefined) delete process.env.HAPI_DISABLE_PEER_TOOLS else process.env.HAPI_DISABLE_PEER_TOOLS = prev } + return { claude, codex, opencode, restore } } describe('peer-tools citation steer across flavors', () => { it('replaces the citation tool steer with the disabled notice when HAPI_DISABLE_PEER_TOOLS is set', async () => { - const { claude, codex, opencode } = await importFlavorPrompts(true) - - const claudePrompt = claude.getSystemPrompt() - expect(claudePrompt).toContain('are disabled on this machine') - expect(claudePrompt).not.toContain('Call "mcp__hapi__inspect_peer"') - - expect(codex.TITLE_INSTRUCTION).toContain('are disabled on this machine') - expect(codex.TITLE_INSTRUCTION).not.toContain('Call "functions.hapi__inspect_peer"') - - expect(opencode.TITLE_INSTRUCTION).toContain('are disabled on this machine') - expect(opencode.TITLE_INSTRUCTION).not.toContain('Call "hapi_inspect_peer"') - - expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).toContain('are disabled on this machine') - expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).not.toContain('Call "hapi_inspect_peer"') + const { claude, codex, opencode, restore } = await importFlavorPrompts(true) + try { + const claudePrompt = claude.getSystemPrompt() + expect(claudePrompt).toContain('are disabled on this machine') + expect(claudePrompt).not.toContain('Call "mcp__hapi__inspect_peer"') + + expect(codex.TITLE_INSTRUCTION).toContain('are disabled on this machine') + expect(codex.TITLE_INSTRUCTION).not.toContain('Call "functions.hapi__inspect_peer"') + + expect(opencode.TITLE_INSTRUCTION).toContain('are disabled on this machine') + expect(opencode.TITLE_INSTRUCTION).not.toContain('Call "hapi_inspect_peer"') + + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).toContain('are disabled on this machine') + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).not.toContain('Call "hapi_inspect_peer"') + } finally { + restore() + } }) it('keeps the citation tool steer by default', async () => { - const { claude, codex, opencode } = await importFlavorPrompts(false) - - expect(claude.getSystemPrompt()).toContain('Call "mcp__hapi__inspect_peer"') - expect(codex.TITLE_INSTRUCTION).toContain('Call "functions.hapi__inspect_peer"') - expect(opencode.TITLE_INSTRUCTION).toContain('Call "hapi_inspect_peer"') - expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).toContain('Call "hapi_inspect_peer"') + const { claude, codex, opencode, restore } = await importFlavorPrompts(false) + try { + expect(claude.getSystemPrompt()).toContain('Call "mcp__hapi__inspect_peer"') + expect(codex.TITLE_INSTRUCTION).toContain('Call "functions.hapi__inspect_peer"') + expect(opencode.TITLE_INSTRUCTION).toContain('Call "hapi_inspect_peer"') + expect(opencode.OPENCODE_NATIVE_TOOL_INSTRUCTION).toContain('Call "hapi_inspect_peer"') + } finally { + restore() + } }) }) From c0d5ebf759db4f2d88e106a1ac9d56f64d98c577 Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:41:17 +0800 Subject: [PATCH 04/14] feat(hub): persist disablePeerTools hub setting and serve it to CLI sessions The peer-tools kill switch shipped runner-env-only, which operators cannot reach from the web UI. Add a hub-persisted global toggle with the same delivery path as sessionSummaryContract: - Settings gains disablePeerTools (default off = tools available) - GET/PUT /api/hub-settings expose it (write stays owner-only; the "at least one field" refine covers the new key) - POST /cli/sessions and GET /cli/sessions/:id responses carry the resolved value so CLI sessions apply it at bootstrap The value is advisory on the wire: older runners strip the unknown field, and HAPI_DISABLE_PEER_TOOLS on a runner still overrides it client-side. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Code --- hub/src/config/disablePeerTools.test.ts | 84 +++++++++++++++++++++++++ hub/src/config/disablePeerTools.ts | 37 +++++++++++ hub/src/config/settings.ts | 6 ++ hub/src/web/routes/cli.ts | 11 +++- hub/src/web/routes/hubSettings.test.ts | 43 +++++++++++-- hub/src/web/routes/hubSettings.ts | 6 +- shared/src/apiTypes.test.ts | 49 ++++++++++++++- shared/src/apiTypes.ts | 16 +++-- 8 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 hub/src/config/disablePeerTools.test.ts create mode 100644 hub/src/config/disablePeerTools.ts diff --git a/hub/src/config/disablePeerTools.test.ts b/hub/src/config/disablePeerTools.test.ts new file mode 100644 index 0000000000..41a760df5b --- /dev/null +++ b/hub/src/config/disablePeerTools.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + readDisablePeerToolsEnabled, + writeDisablePeerToolsEnabled +} from './disablePeerTools' +import { getSettingsFile, updateSettings, writeSettings } from './settings' + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('disablePeerTools setting', () => { + it('defaults to off when settings.json is missing or unset', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'hapi-dpt-')) + directories.push(dataDir) + expect(await readDisablePeerToolsEnabled(dataDir)).toBe(false) + + await writeSettings(getSettingsFile(dataDir), { listenPort: 3006 }) + expect(await readDisablePeerToolsEnabled(dataDir)).toBe(false) + }) + + it('persists true across reads', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'hapi-dpt-')) + directories.push(dataDir) + expect(await writeDisablePeerToolsEnabled(dataDir, true)).toBe(true) + expect(await readDisablePeerToolsEnabled(dataDir)).toBe(true) + + const raw = JSON.parse(await readFile(getSettingsFile(dataDir), 'utf8')) as { + disablePeerTools?: boolean + } + expect(raw.disablePeerTools).toBe(true) + + await writeDisablePeerToolsEnabled(dataDir, false) + expect(await readDisablePeerToolsEnabled(dataDir)).toBe(false) + }) + + it('serializes concurrent writers without clobbering unrelated fields', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'hapi-dpt-race-')) + directories.push(dataDir) + const settingsFile = getSettingsFile(dataDir) + await writeSettings(settingsFile, { + cliApiToken: 'keep-me', + machineId: 'machine-1', + relayAuthKey: 'relay-keep', + listenPort: 3006 + }) + + const results = await Promise.all([ + writeDisablePeerToolsEnabled(dataDir, true), + updateSettings(settingsFile, (current) => ({ + settings: { + ...current, + publicUrl: 'https://example.test' + }, + result: undefined + })), + writeDisablePeerToolsEnabled(dataDir, true) + ]) + + expect(results[0]).toBe(true) + expect(results[2]).toBe(true) + + const raw = JSON.parse(await readFile(settingsFile, 'utf8')) as { + cliApiToken?: string + machineId?: string + relayAuthKey?: string + listenPort?: number + publicUrl?: string + disablePeerTools?: boolean + } + expect(raw.cliApiToken).toBe('keep-me') + expect(raw.machineId).toBe('machine-1') + expect(raw.relayAuthKey).toBe('relay-keep') + expect(raw.listenPort).toBe(3006) + expect(raw.publicUrl).toBe('https://example.test') + expect(raw.disablePeerTools).toBe(true) + expect(await readDisablePeerToolsEnabled(dataDir)).toBe(true) + }) +}) diff --git a/hub/src/config/disablePeerTools.ts b/hub/src/config/disablePeerTools.ts new file mode 100644 index 0000000000..bc5437e0ac --- /dev/null +++ b/hub/src/config/disablePeerTools.ts @@ -0,0 +1,37 @@ +import { + getSettingsFile, + readSettingsOrThrow, + updateSettings, + type Settings +} from './settings' + +/** + * Hub-persisted opt-out for the peer-session MCP tools (`list_peers` / + * `inspect_peer` / `ping_peer`). Default is off (tools available). Env + * `HAPI_DISABLE_PEER_TOOLS` on the CLI process remains a per-runner escape + * hatch and is resolved client-side. + */ +export function isDisablePeerToolsSettingEnabled(settings: Settings): boolean { + return settings.disablePeerTools === true +} + +export async function readDisablePeerToolsEnabled(dataDir: string): Promise { + const settings = await readSettingsOrThrow(getSettingsFile(dataDir)) + return isDisablePeerToolsSettingEnabled(settings) +} + +export async function writeDisablePeerToolsEnabled( + dataDir: string, + disabled: boolean +): Promise { + return updateSettings(getSettingsFile(dataDir), (current) => { + const settings = { + ...current, + disablePeerTools: disabled + } + return { + settings, + result: settings.disablePeerTools === true + } + }) +} diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index 66a246773d..086433be56 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -46,6 +46,12 @@ export interface Settings { * Default off: render/copy strip the footer; store stays raw. */ sessionSummaryInChat?: boolean + /** + * When true, CLI hides the peer-session MCP tools (`list_peers` / + * `inspect_peer` / `ping_peer`) from wrapped agents so sessions cannot + * browse or message each other. Default off. + */ + disablePeerTools?: boolean /** * Hub-side provider API keys / endpoints managed from Settings. * Env vars still win when set at process start (ops override). diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index 17d116ef6b..d7f366ede5 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -9,6 +9,7 @@ import { } from '@hapi/protocol' import { getConfiguration } from '../../configuration' import { readSessionSummaryContractEnabled } from '../../config/sessionSummaryContract' +import { readDisablePeerToolsEnabled } from '../../config/disablePeerTools' import { constantTimeEquals } from '../../utils/crypto' import { parseAccessToken } from '../../utils/accessToken' import type { Machine, Session, SyncEngine } from '../../sync/syncEngine' @@ -143,7 +144,10 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono SyncEngine | null): Hono { diff --git a/hub/src/web/routes/hubSettings.test.ts b/hub/src/web/routes/hubSettings.test.ts index 10ddfd6ae6..e1627cdcca 100644 --- a/hub/src/web/routes/hubSettings.test.ts +++ b/hub/src/web/routes/hubSettings.test.ts @@ -34,7 +34,8 @@ describe('GET/PUT /api/hub-settings', () => { expect(response.headers.get('cache-control')).toBe('no-store') expect(await response.json()).toEqual({ sessionSummaryContract: false, - sessionSummaryInChat: false + sessionSummaryInChat: false, + disablePeerTools: false }) }) @@ -48,13 +49,15 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(200) expect(await put.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: false + sessionSummaryInChat: false, + disablePeerTools: false }) const get = await app.request('/api/hub-settings') expect(await get.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: false + sessionSummaryInChat: false, + disablePeerTools: false }) }) @@ -70,7 +73,8 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(200) expect(await put.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: true + sessionSummaryInChat: true, + disablePeerTools: false }) }) @@ -109,7 +113,8 @@ describe('GET/PUT /api/hub-settings', () => { expect(get.status).toBe(200) expect(await get.json()).toEqual({ sessionSummaryContract: false, - sessionSummaryInChat: true + sessionSummaryInChat: true, + disablePeerTools: false }) const put = await tenantApp.request('/api/hub-settings', { @@ -120,6 +125,31 @@ describe('GET/PUT /api/hub-settings', () => { expect(put.status).toBe(403) }) + it('persists peer-tools toggle alone without touching other settings', async () => { + const { app, dataDir } = await createApp() + await writeSessionSummaryContractEnabled(dataDir, true) + await writeSessionSummaryInChatEnabled(dataDir, true) + + const put = await app.request('/api/hub-settings', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ disablePeerTools: true }) + }) + expect(put.status).toBe(200) + expect(await put.json()).toEqual({ + sessionSummaryContract: true, + sessionSummaryInChat: true, + disablePeerTools: true + }) + + const get = await app.request('/api/hub-settings') + expect(await get.json()).toEqual({ + sessionSummaryContract: true, + sessionSummaryInChat: true, + disablePeerTools: true + }) + }) + it('survives a prior write via settings helpers', async () => { const { app, dataDir } = await createApp() await writeSessionSummaryContractEnabled(dataDir, true) @@ -127,7 +157,8 @@ describe('GET/PUT /api/hub-settings', () => { const response = await app.request('/api/hub-settings') expect(await response.json()).toEqual({ sessionSummaryContract: true, - sessionSummaryInChat: true + sessionSummaryInChat: true, + disablePeerTools: false }) }) }) diff --git a/hub/src/web/routes/hubSettings.ts b/hub/src/web/routes/hubSettings.ts index 3d2a087789..90d8bd9b78 100644 --- a/hub/src/web/routes/hubSettings.ts +++ b/hub/src/web/routes/hubSettings.ts @@ -13,7 +13,8 @@ const OWNER_ONLY_ERROR = 'Hub settings are only available to the hub owner' function toHubSettings(settings: Settings): HubSettingsResponse { return { sessionSummaryContract: settings.sessionSummaryContract === true, - sessionSummaryInChat: settings.sessionSummaryInChat === true + sessionSummaryInChat: settings.sessionSummaryInChat === true, + disablePeerTools: settings.disablePeerTools === true } } @@ -45,6 +46,9 @@ export function createHubSettingsRoutes(dataDir: string): Hono { if (parsed.data.sessionSummaryInChat !== undefined) { settings.sessionSummaryInChat = parsed.data.sessionSummaryInChat } + if (parsed.data.disablePeerTools !== undefined) { + settings.disablePeerTools = parsed.data.disablePeerTools + } return { settings, result: toHubSettings(settings) diff --git a/shared/src/apiTypes.test.ts b/shared/src/apiTypes.test.ts index 95eaca2e56..931411ecbf 100644 --- a/shared/src/apiTypes.test.ts +++ b/shared/src/apiTypes.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest' import { ClearOpencodeSessionCallbackRequestSchema, ClearOpencodeSessionResponseSchema, + CreateSessionResponseSchema, + HubSettingsResponseSchema, ListCodexSessionsRpcResponseSchema, ListPiSessionsRpcResponseSchema, MessagesQuerySchema, - SendMessageRequestSchema + SendMessageRequestSchema, + UpdateHubSettingsRequestSchema } from './apiTypes' describe('ListCodexSessionsRpcResponseSchema', () => { @@ -145,3 +148,47 @@ describe('SendMessageRequestSchema deliveryMode', () => { } }) }) + +describe('disablePeerTools hub setting wire shapes', () => { + it('UpdateHubSettingsRequestSchema accepts a lone disablePeerTools payload but still rejects empty bodies', () => { + expect(UpdateHubSettingsRequestSchema.safeParse({ disablePeerTools: true }).success).toBe(true) + expect(UpdateHubSettingsRequestSchema.safeParse({}).success).toBe(false) + expect(UpdateHubSettingsRequestSchema.safeParse({ disablePeerTools: 'yes' }).success).toBe(false) + }) + + it('HubSettingsResponseSchema requires disablePeerTools as a boolean', () => { + const parsed = HubSettingsResponseSchema.safeParse({ + sessionSummaryContract: false, + sessionSummaryInChat: false, + disablePeerTools: true + }) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.disablePeerTools).toBe(true) + expect(HubSettingsResponseSchema.safeParse({ + sessionSummaryContract: false, + sessionSummaryInChat: false + }).success).toBe(false) + }) + + it('CreateSessionResponseSchema leaves disablePeerTools optional for older hubs', () => { + const session = { + id: 's1', + namespace: 'default', + seq: 0, + createdAt: 0, + updatedAt: 0, + active: false, + activeAt: null, + metadata: null, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0 + } + expect(CreateSessionResponseSchema.safeParse({ session }).success).toBe(true) + const parsed = CreateSessionResponseSchema.safeParse({ session, disablePeerTools: true }) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.disablePeerTools).toBe(true) + }) +}) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 4851bb0a2f..9132c2b944 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -65,7 +65,9 @@ export type CliMessagesResponse = z.infer export const CreateSessionResponseSchema = z.object({ session: SessionSchema, /** Hub opt-in for AGENT_NOTIFY_SUMMARY prompt injection (default off when omitted). */ - sessionSummaryContract: z.boolean().optional() + sessionSummaryContract: z.boolean().optional(), + /** Hub opt-out: hide peer-session tools from agents (default off / tools available). */ + disablePeerTools: z.boolean().optional() }) export type CreateSessionResponse = z.infer @@ -73,7 +75,9 @@ export type CreateSessionResponse = z.infer export const HubSettingsResponseSchema = z.object({ sessionSummaryContract: z.boolean(), /** Show compact AGENT_NOTIFY_SUMMARY in chat (default off / hide). */ - sessionSummaryInChat: z.boolean() + sessionSummaryInChat: z.boolean(), + /** Hide peer-session tools (list_peers / inspect_peer / ping_peer) from agents (default off). */ + disablePeerTools: z.boolean() }) export type HubSettingsResponse = z.infer @@ -81,10 +85,14 @@ export type HubSettingsResponse = z.infer export const UpdateHubSettingsRequestSchema = z .object({ sessionSummaryContract: z.boolean().optional(), - sessionSummaryInChat: z.boolean().optional() + sessionSummaryInChat: z.boolean().optional(), + disablePeerTools: z.boolean().optional() }) .refine( - (data) => data.sessionSummaryContract !== undefined || data.sessionSummaryInChat !== undefined, + (data) => + data.sessionSummaryContract !== undefined || + data.sessionSummaryInChat !== undefined || + data.disablePeerTools !== undefined, { message: 'At least one hub setting field is required' } ) From 97ff85340e8748087fd6fc66f805aefb589d6e32 Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:43:09 +0800 Subject: [PATCH 05/14] feat(cli): apply hub disablePeerTools preference to the peer-tools gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI gate now resolves like sessionSummaryContract: an explicit HAPI_DISABLE_PEER_TOOLS value wins in both directions (0/false/off/no keep the tools, anything else — including typos — disables them, so the kill switch fails safe), otherwise the hub preference applied at session bootstrap decides, otherwise tools stay available. - peerTools gains applyHubPeerToolsDisabled module state - /cli/sessions create + get responses feed it into the gate before any flavor launcher or MCP server build reads the toggle - the core refusal message now names both sources (hub administrator or env) since either can be the active one The stdio bridge keeps env-only semantics: it runs in its own child process that never bootstraps a session, and its --tools list already comes from the (pruned) session toolNames. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Code --- cli/src/api/api.ts | 7 ++++ cli/src/claude/utils/startHappyServer.test.ts | 14 +++++++ cli/src/modules/common/peerTools.test.ts | 39 +++++++++++++++++-- cli/src/modules/common/peerTools.ts | 34 +++++++++++++--- cli/src/modules/pingPeer/pingPeer.test.ts | 39 +++++++++++++++++++ cli/src/modules/pingPeer/pingPeer.ts | 9 +++-- 6 files changed, 129 insertions(+), 13 deletions(-) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index f079741dbe..a1364824ae 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -1,6 +1,7 @@ import axios from 'axios' import type { AgentState, ClearOpencodeSessionCallbackRequest, ClearOpencodeSessionResponse, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types' import { applyHubSessionSummaryContract } from '@/modules/common/sessionSummaryInstruction' +import { applyHubPeerToolsDisabled } from '@/modules/common/peerTools' import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol' import { AgentStateSchema, @@ -91,6 +92,9 @@ export class ApiClient { if (typeof parsed.data.sessionSummaryContract === 'boolean') { applyHubSessionSummaryContract(parsed.data.sessionSummaryContract) } + if (typeof parsed.data.disablePeerTools === 'boolean') { + applyHubPeerToolsDisabled(parsed.data.disablePeerTools) + } const raw = parsed.data.session @@ -147,6 +151,9 @@ export class ApiClient { if (typeof parsed.data.sessionSummaryContract === 'boolean') { applyHubSessionSummaryContract(parsed.data.sessionSummaryContract) } + if (typeof parsed.data.disablePeerTools === 'boolean') { + applyHubPeerToolsDisabled(parsed.data.disablePeerTools) + } const raw = parsed.data.session const metadata = (() => { diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index fda93291ba..c1f39c152f 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -5,6 +5,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { ApiSessionClient } from '@/api/apiSession' +import { applyHubPeerToolsDisabled, resetPeerToolsForTests } from '@/modules/common/peerTools' import { startHappyServer, toClaudeAllowedHapiMcpTools } from './startHappyServer' type ToolResult = { @@ -342,6 +343,7 @@ describe('startHappyServer peer tools kill switch', () => { } else { process.env.HAPI_DISABLE_PEER_TOOLS = originalFlag } + resetPeerToolsForTests() }) async function startServer(): Promise<{ url: string; toolNames: string[] }> { @@ -380,4 +382,16 @@ describe('startHappyServer peer tools kill switch', () => { expect(names).not.toContain('inspect_peer') expect(names).toEqual(expect.arrayContaining(['display_image', 'display_video', 'display_media'])) }) + + it('omits peer tools from registration and toolNames when the hub preference disables them', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + applyHubPeerToolsDisabled(true) + const { toolNames } = await startServer() + expect(toolNames).toEqual(['change_title', 'display_image', 'display_video', 'display_media']) + const tools = await client!.listTools() + const names = tools.tools.map((tool) => tool.name) + expect(names).not.toContain('list_peers') + expect(names).not.toContain('ping_peer') + expect(names).not.toContain('inspect_peer') + }) }) diff --git a/cli/src/modules/common/peerTools.test.ts b/cli/src/modules/common/peerTools.test.ts index ed4fea3798..33dc7a76ac 100644 --- a/cli/src/modules/common/peerTools.test.ts +++ b/cli/src/modules/common/peerTools.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' -import { isPeerToolsDisabled } from './peerTools' +import { + applyHubPeerToolsDisabled, + isPeerToolsDisabled, + resetPeerToolsForTests +} from './peerTools' describe('isPeerToolsDisabled', () => { const original = process.env.HAPI_DISABLE_PEER_TOOLS @@ -10,6 +14,7 @@ describe('isPeerToolsDisabled', () => { } else { process.env.HAPI_DISABLE_PEER_TOOLS = original } + resetPeerToolsForTests() }) it('defaults to enabled (peer tools visible)', () => { @@ -22,13 +27,41 @@ describe('isPeerToolsDisabled', () => { expect(isPeerToolsDisabled()).toBe(true) }) - it('stays enabled for unrelated values', () => { - process.env.HAPI_DISABLE_PEER_TOOLS = '0' + it.each(['0', 'false', 'off', 'no'])('keeps peer tools for explicit off spelling %s', (value) => { + process.env.HAPI_DISABLE_PEER_TOOLS = value expect(isPeerToolsDisabled()).toBe(false) }) + it('disables peer tools for unrecognized values (fail-safe for a kill switch)', () => { + process.env.HAPI_DISABLE_PEER_TOOLS = 'banana' + expect(isPeerToolsDisabled()).toBe(true) + }) + it('reads from an explicit env object', () => { expect(isPeerToolsDisabled({ HAPI_DISABLE_PEER_TOOLS: '1' } as NodeJS.ProcessEnv)).toBe(true) + expect(isPeerToolsDisabled({ HAPI_DISABLE_PEER_TOOLS: '0' } as NodeJS.ProcessEnv)).toBe(false) expect(isPeerToolsDisabled({} as NodeJS.ProcessEnv)).toBe(false) }) + + it('applies the hub preference when env is unset or empty', () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + applyHubPeerToolsDisabled(true) + expect(isPeerToolsDisabled()).toBe(true) + applyHubPeerToolsDisabled(false) + expect(isPeerToolsDisabled()).toBe(false) + + process.env.HAPI_DISABLE_PEER_TOOLS = '' + applyHubPeerToolsDisabled(true) + expect(isPeerToolsDisabled()).toBe(true) + }) + + it('lets an explicit env value override the hub preference in both directions', () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + applyHubPeerToolsDisabled(false) + expect(isPeerToolsDisabled()).toBe(true) + + process.env.HAPI_DISABLE_PEER_TOOLS = '0' + applyHubPeerToolsDisabled(true) + expect(isPeerToolsDisabled()).toBe(false) + }) }) diff --git a/cli/src/modules/common/peerTools.ts b/cli/src/modules/common/peerTools.ts index 18c1588860..75dd0e1048 100644 --- a/cli/src/modules/common/peerTools.ts +++ b/cli/src/modules/common/peerTools.ts @@ -2,18 +2,40 @@ * Kill switch for the peer-session MCP tools (`list_peers` / `inspect_peer` / * `ping_peer`). * - * Set `HAPI_DISABLE_PEER_TOOLS=1` in the runner environment to stop wrapped - * agents from browsing or messaging other HAPI sessions (peer pollution): - * the tools are not registered on the session MCP server, not advertised via - * `--allowedTools` / `--tools`, and the peer steer block is dropped from + * Wrapped agents with these tools can browse the session list and message + * other sessions, polluting peer contexts on busy runners. When the switch is + * on, the tools are not registered on the session MCP server, not advertised + * via `--allowedTools` / `--tools`, and the peer steer block is dropped from * flavor system prompts. The core module refuses as defense in depth, so * `hapi ping-peer` / `inspect-peer` from inside a wrapped agent shell fails * loudly instead of silently bypassing the switch. Operators who keep the - * variable unset in their interactive shell keep full CLI access. + * switch off in their interactive terminal keep full CLI access. + * + * Resolution order (mirrors sessionSummaryContract): + * 1. Explicit HAPI_DISABLE_PEER_TOOLS env (0|false|off|no → off; anything else → on) + * 2. Hub preference applied via `applyHubPeerToolsDisabled` (from session bootstrap) + * 3. Default: off (tools available) */ export const PEER_TOOL_NAMES = ['list_peers', 'ping_peer', 'inspect_peer'] as const; +let hubPreference: boolean | undefined + +/** Apply the hub-resolved toggle from session create/get bootstrap. */ +export function applyHubPeerToolsDisabled(disabled: boolean): void { + hubPreference = disabled +} + +/** Test-only: clear hub preference between cases. */ +export function resetPeerToolsForTests(): void { + hubPreference = undefined +} + export function isPeerToolsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { - return ['true', '1', 'yes'].includes((env.HAPI_DISABLE_PEER_TOOLS ?? '').toLowerCase()); + const raw = env.HAPI_DISABLE_PEER_TOOLS + if (raw !== undefined && raw !== '') { + const normalized = raw.trim().toLowerCase() + return !(normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') + } + return hubPreference === true } diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts index 8c5e2ff3a1..037683d443 100644 --- a/cli/src/modules/pingPeer/pingPeer.test.ts +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -8,6 +8,7 @@ import { resolveSessionByPrefix, type PingPeerSessionSummary } from './pingPeer' +import { applyHubPeerToolsDisabled, resetPeerToolsForTests } from '@/modules/common/peerTools' type MockResponse = { status: number @@ -709,3 +710,41 @@ describe('HAPI_DISABLE_PEER_TOOLS kill switch', () => { expect(exitCodeForPingPeerError(new PingPeerError('disabled', 'disabled on this machine'))).toBe(2) }) }) + +describe('hub disablePeerTools preference', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS + + beforeEach(() => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + applyHubPeerToolsDisabled(true) + }) + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original + } + resetPeerToolsForTests() + }) + + it('pingPeer refuses before any network call', async () => { + await expect(pingPeer({ sessionIdPrefix: 'abc', message: 'hi' })) + .rejects.toMatchObject({ code: 'disabled' }) + }) + + it('inspectPeer refuses before any network call', async () => { + await expect(inspectPeer({ sessionIdPrefix: 'abc' })) + .rejects.toMatchObject({ code: 'disabled' }) + }) + + it('listPeerSessions refuses before any network call', async () => { + await expect(listPeerSessions()).rejects.toMatchObject({ code: 'disabled' }) + }) + + it('mentions the hub administrator in the refusal message', async () => { + const failure = await pingPeer({ sessionIdPrefix: 'abc', message: 'hi' }).catch((error) => error) + expect(failure).toMatchObject({ code: 'disabled' }) + expect(String(failure.message)).toContain('hub administrator') + }) +}) diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index dfd2d09e89..5ca705bf19 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -37,15 +37,16 @@ export class PingPeerError extends Error { } /** - * Defense-in-depth gate for HAPI_DISABLE_PEER_TOOLS: the MCP server and CLI - * surface refuse earlier, but any other caller (stale --tools bridge, shell - * `hapi ping-peer` from a wrapped agent) lands here and fails loudly. + * Defense-in-depth gate for the peer-tools switch (hub toggle or + * HAPI_DISABLE_PEER_TOOLS): the MCP server and CLI surface refuse earlier, + * but any other caller (stale --tools bridge, shell `hapi ping-peer` from a + * wrapped agent) lands here and fails loudly. */ function assertPeerToolsEnabled(): void { if (isPeerToolsDisabled()) { throw new PingPeerError( 'disabled', - 'peer session tools are disabled on this machine (HAPI_DISABLE_PEER_TOOLS is set)' + 'peer session tools are disabled (disabled by your hub administrator, or HAPI_DISABLE_PEER_TOOLS is set)' ) } } From aafbe07a576000a746e6dbd786c9671712e9e40e Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:46:25 +0800 Subject: [PATCH 06/14] feat(cli): resolve peer-tools steer at call time so hub toggle applies Convert the three flavor prompts (claude BASE_SYSTEM_PROMPT IIFE const, codex/opencode frozen TITLE_INSTRUCTION / OPENCODE_NATIVE_TOOL_INSTRUCTION consts) into env-parameterized builders called from the existing getters. The hub disablePeerTools preference arrives during session bootstrap, after module import, so the import-time snapshots could never reflect it. Deprecated const exports stay as import-time process.env snapshots for the existing byte-identity tests; production call sites are all no-arg getter calls and pick up the new resolution automatically. Extend systemPrompt.peerTools.test.ts with hub-preference cases through the getters (env unset + hub disabled -> disabled notice in all flavors; explicit env overrides hub in both directions). via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Code --- .../utils/systemPrompt.peerTools.test.ts | 49 ++++++++++++++++++- cli/src/claude/utils/systemPrompt.ts | 25 ++++++---- cli/src/codex/utils/systemPrompt.ts | 24 +++++++-- cli/src/opencode/utils/systemPrompt.ts | 41 +++++++++++++--- 4 files changed, 115 insertions(+), 24 deletions(-) diff --git a/cli/src/claude/utils/systemPrompt.peerTools.test.ts b/cli/src/claude/utils/systemPrompt.peerTools.test.ts index a7027c6c89..8a8f46b199 100644 --- a/cli/src/claude/utils/systemPrompt.peerTools.test.ts +++ b/cli/src/claude/utils/systemPrompt.peerTools.test.ts @@ -14,16 +14,17 @@ async function importFlavorPrompts(disabled: boolean) { const prev = process.env.HAPI_DISABLE_PEER_TOOLS if (disabled) process.env.HAPI_DISABLE_PEER_TOOLS = '1' else delete process.env.HAPI_DISABLE_PEER_TOOLS - const [claude, codex, opencode] = await Promise.all([ + const [claude, codex, opencode, peerTools] = await Promise.all([ import('./systemPrompt'), import('../../codex/utils/systemPrompt'), import('../../opencode/utils/systemPrompt'), + import('../../modules/common/peerTools'), ]) const restore = () => { if (prev === undefined) delete process.env.HAPI_DISABLE_PEER_TOOLS else process.env.HAPI_DISABLE_PEER_TOOLS = prev } - return { claude, codex, opencode, restore } + return { claude, codex, opencode, peerTools, restore } } describe('peer-tools citation steer across flavors', () => { @@ -58,4 +59,48 @@ describe('peer-tools citation steer across flavors', () => { restore() } }) + + it('resolves the disabled notice at call time from the hub preference', async () => { + const { claude, codex, opencode, peerTools, restore } = await importFlavorPrompts(false) + try { + peerTools.applyHubPeerToolsDisabled(true) + expect(claude.getSystemPrompt()).toContain('are disabled on this machine') + expect(claude.getSystemPrompt()).not.toContain('Call "mcp__hapi__inspect_peer"') + expect(codex.getCodexSystemPrompt()).toContain('are disabled on this machine') + expect(codex.getCodexSystemPrompt()).not.toContain('Call "functions.hapi__inspect_peer"') + expect(opencode.getTitleInstruction()).toContain('are disabled on this machine') + expect(opencode.getTitleInstruction()).not.toContain('Call "hapi_inspect_peer"') + expect(opencode.getOpencodeNativeToolInstruction()).toContain('are disabled on this machine') + expect(opencode.getOpencodeNativeToolInstruction()).not.toContain('Call "hapi_inspect_peer"') + } finally { + peerTools.resetPeerToolsForTests() + restore() + } + }) + + it('lets an explicit env value override the hub preference in both directions', async () => { + const prev = process.env.HAPI_DISABLE_PEER_TOOLS + try { + vi.resetModules() + process.env.HAPI_DISABLE_PEER_TOOLS = '0' + const [claude, peerTools] = await Promise.all([ + import('./systemPrompt'), + import('../../modules/common/peerTools'), + ]) + peerTools.applyHubPeerToolsDisabled(true) + // hub says disabled but the operator explicitly re-enabled via env + expect(claude.getSystemPrompt()).toContain('Call "mcp__hapi__inspect_peer"') + expect(claude.getSystemPrompt()).not.toContain('are disabled on this machine') + + peerTools.applyHubPeerToolsDisabled(false) + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + expect(claude.getSystemPrompt()).not.toContain('Call "mcp__hapi__inspect_peer"') + expect(claude.getSystemPrompt()).toContain('are disabled on this machine') + + peerTools.resetPeerToolsForTests() + } finally { + if (prev === undefined) delete process.env.HAPI_DISABLE_PEER_TOOLS + else process.env.HAPI_DISABLE_PEER_TOOLS = prev + } + }) }) diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 1ed7241eec..3ccd6ca2bc 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -6,19 +6,23 @@ import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; /** - * Base system prompt shared across all configurations + * Base system prompt shared across all configurations. The peer steer is + * resolved at call time so the hub toggle applied at session bootstrap + * (after module import) is honored. */ -const BASE_SYSTEM_PROMPT = (() => trimIdent(` +function buildBaseSystemPrompt(env: NodeJS.ProcessEnv): string { + return trimIdent(` Use the title tool sparingly. For a new chat, call the tool "mcp__hapi__change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. ${DISPLAY_IMAGE_PROMPT_CLAUDE} ${DISPLAY_VIDEO_PROMPT_CLAUDE} ${DISPLAY_MEDIA_PROMPT_CLAUDE} - ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled(env) ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'mcp__hapi__inspect_peer', pingTool: 'mcp__hapi__ping_peer', listPeersTool: 'mcp__hapi__list_peers', })} -`))(); +`); +} /** * Co-authored-by credits to append when enabled @@ -35,13 +39,14 @@ const CO_AUTHORED_CREDITS = (() => trimIdent(` /** * Resolve the Claude append-system-prompt text. - * Co-Authored-By is read once from Claude settings; session-summary contract - * is resolved at call time so hub toggle / env apply after session bootstrap. + * Co-Authored-By is read once from Claude settings; the peer steer and + * session-summary contract are resolved at call time so hub toggle / env + * apply after session bootstrap. */ -export function getSystemPrompt(): string { +export function getSystemPrompt(env: NodeJS.ProcessEnv = process.env): string { const includeCoAuthored = shouldIncludeCoAuthoredBy(); const base = includeCoAuthored - ? BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS - : BASE_SYSTEM_PROMPT; - return withSessionSummaryInstruction(base); + ? buildBaseSystemPrompt(env) + '\n\n' + CO_AUTHORED_CREDITS + : buildBaseSystemPrompt(env); + return withSessionSummaryInstruction(base, env); } diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index 71d74583f0..e2f00beed9 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -16,7 +16,8 @@ import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryIn * Note: Codex exposes MCP tools under the `functions.` namespace, * so the tool is called as `functions.hapi__change_title`. */ -export const TITLE_INSTRUCTION = trimIdent(` +function buildCodexTitleInstruction(env: NodeJS.ProcessEnv): string { + return trimIdent(` Use the title tool sparingly. For a new chat, call it once after the user's initial request is clear, and set a concise task title. Prefer calling functions.hapi__change_title. If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title. @@ -25,20 +26,33 @@ export const TITLE_INSTRUCTION = trimIdent(` ${DISPLAY_IMAGE_PROMPT_CODEX} ${DISPLAY_VIDEO_PROMPT_CODEX} ${DISPLAY_MEDIA_PROMPT_CODEX} - ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled(env) ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'functions.hapi__inspect_peer', pingTool: 'functions.hapi__ping_peer', listPeersTool: 'functions.hapi__list_peers', })} `); +} + +/** + * Import-time snapshot resolved from process.env only; does not reflect the + * hub toggle applied at session bootstrap. Kept for existing tests — use + * getCodexSystemPrompt(env) in production paths. + * + * @deprecated use getCodexSystemPrompt(env) + */ +export const TITLE_INSTRUCTION = buildCodexTitleInstruction(process.env) /** * The system prompt to inject via developer_instructions in local mode. - * Session-summary contract is resolved at call time (hub toggle / env). + * Session-summary contract and the peer steer are resolved at call time + * (hub toggle / env). */ export function getCodexSystemPrompt(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) + return withSessionSummaryInstruction(buildCodexTitleInstruction(env), env) } -/** Alias kept for existing call sites / tests that expect a string constant name. */ +/** + * @deprecated Import-time snapshot; use getCodexSystemPrompt(env). + */ export const codexSystemPrompt = TITLE_INSTRUCTION diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index 6b805d990e..b23f681b07 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -19,43 +19,70 @@ import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryIn /** * Title and display_image / display_video / display_media instructions for OpenCode to call the hapi MCP tools. + * The peer steer is resolved at call time so the hub toggle applied at + * session bootstrap (after module import) is honored. */ -export const TITLE_INSTRUCTION = trimIdent(` +function buildOpencodeTitleInstruction(env: NodeJS.ProcessEnv): string { + return trimIdent(` ${HAPI_MCP_BRIDGE_PROMPT} - ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled(env) ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', listPeersTool: 'hapi_list_peers', })} ${SKILL_LOOKUP_INSTRUCTION} `); +} export function getTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) + return withSessionSummaryInstruction(buildOpencodeTitleInstruction(env), env) } /** * Tool instructions for native ACP sessions. Title updates come from ACP, so - * advertise only the MCP tools that remain available to the model. + * advertise only the MCP tools that remain available to the model. The peer + * steer is resolved at call time (hub toggle / env). */ -export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` +function buildOpencodeNativeToolInstruction(env: NodeJS.ProcessEnv): string { + return trimIdent(` ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} ${DISPLAY_MEDIA_PROMPT_HAPI_MCP} - ${isPeerToolsDisabled() ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ + ${isPeerToolsDisabled(env) ? SESSION_CITATION_DISABLED_STEER : buildSessionCitationSteerInstruction({ inspectTool: 'hapi_inspect_peer', pingTool: 'hapi_ping_peer', listPeersTool: 'hapi_list_peers', })} ${SKILL_LOOKUP_INSTRUCTION} `); +} export function getOpencodeNativeToolInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION, env) + return withSessionSummaryInstruction(buildOpencodeNativeToolInstruction(env), env) } +/** + * Import-time snapshot resolved from process.env only; does not reflect the + * hub toggle applied at session bootstrap. Kept for existing tests — use + * getOpencodeNativeToolInstruction(env) in production paths. + * + * @deprecated use getOpencodeNativeToolInstruction(env) + */ +export const OPENCODE_NATIVE_TOOL_INSTRUCTION = buildOpencodeNativeToolInstruction(process.env) + +/** + * Import-time snapshot resolved from process.env only; does not reflect the + * hub toggle applied at session bootstrap. Kept for existing tests — use + * getTitleInstruction(env) in production paths. + * + * @deprecated use getTitleInstruction(env) + */ +export const TITLE_INSTRUCTION = buildOpencodeTitleInstruction(process.env) + /** * The system prompt to inject for OpenCode sessions. + * + * @deprecated use getTitleInstruction(env) */ export const opencodeSystemPrompt = TITLE_INSTRUCTION; From 2b79be55441ea4595919d1138ee85fa6d72c6a5d Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:49:21 +0800 Subject: [PATCH 07/14] feat(web): add hub-wide peer-tools opt-out toggle to Settings -> General Owner-gated section mirroring the session summary switches: reads disablePeerTools from GET /api/hub-settings and PATCHes it alone via updateHubSettings. Locale strings for en / zh-CN / ru; the description documents new-session-only semantics, the terminal ping-peer exemption, and the per-runner env override. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Code --- web/src/lib/locales/en.ts | 4 ++++ web/src/lib/locales/ru.ts | 4 ++++ web/src/lib/locales/zh-CN.ts | 4 ++++ web/src/routes/settings/general.tsx | 20 ++++++++++++++++- web/src/routes/settings/index.test.tsx | 30 +++++++++++++++++++++----- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 1ba91a318b..994ead3252 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -826,6 +826,10 @@ export default { 'settings.general.sessionSummaryContract.desc': 'Off by default. When enabled, supported agents are asked to add a trailing AGENT_NOTIFY_SUMMARY line after each turn for notifications and background work records. Applies to new/resumed sessions. (Supported: Claude, Codex, OpenCode, remote Grok; not yet supported: local Grok, Cursor)', 'settings.general.sessionSummaryInChat': 'Show status summaries in chat', 'settings.general.sessionSummaryInChat.desc': 'Only affects display in chat and copied content; it does not affect summary generation, notifications, or background work records. When on, a status row is shown; when off, it is hidden. Stored messages remain unchanged.', + 'settings.general.peerTools.title': 'Peer session tools', + 'settings.general.peerTools.description': 'Control whether agents can browse or contact other sessions on this hub.', + 'settings.general.disablePeerTools': 'Disable peer session tools', + 'settings.general.disablePeerTools.desc': 'Off by default. When enabled, agents in new sessions no longer see the list_peers / inspect_peer / ping_peer tools and the prompt steer is removed, preventing cross-session context pollution. Terminal `hapi ping-peer` keeps working. The per-runner HAPI_DISABLE_PEER_TOOLS environment variable overrides this value.', 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', diff --git a/web/src/lib/locales/ru.ts b/web/src/lib/locales/ru.ts index 9204529e09..0454e3a94f 100644 --- a/web/src/lib/locales/ru.ts +++ b/web/src/lib/locales/ru.ts @@ -828,6 +828,10 @@ export default { 'settings.general.sessionSummaryContract.desc': 'По умолчанию выключено. При включении поддерживаемых агентов просят добавлять после каждого хода завершающую строку AGENT_NOTIFY_SUMMARY для уведомлений и записей о фоновой работе. Применяется к новым/возобновлённым сессиям. (Поддерживаются: Claude, Codex, OpenCode, удалённый Grok; пока не поддерживаются: локальный Grok, Cursor)', 'settings.general.sessionSummaryInChat': 'Показывать сводки статуса в чате', 'settings.general.sessionSummaryInChat.desc': 'Влияет только на отображение в чате и скопированном содержимом; не влияет на формирование сводок, уведомления или записи о фоновой работе. При включении показывается строка статуса, при выключении она скрыта. Сохранённые сообщения не изменяются.', + 'settings.general.peerTools.title': 'Инструменты peer-сессий', + 'settings.general.peerTools.description': 'Управление тем, могут ли агенты просматривать другие сессии на этом hub и связываться с ними.', + 'settings.general.disablePeerTools': 'Отключить инструменты peer-сессий', + 'settings.general.disablePeerTools.desc': 'По умолчанию выключено. При включении агенты в новых сессиях больше не видят инструменты list_peers / inspect_peer / ping_peer, а соответствующая подсказка в системном промпте удаляется — это предотвращает загрязнение контекста между сессиями. Терминальная команда hapi ping-peer продолжает работать. Значение можно переопределить переменной окружения HAPI_DISABLE_PEER_TOOLS на конкретной машине.', 'settings.language.title': 'Язык', 'settings.language.label': 'Язык', 'settings.display.title': 'Отображение', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 18f7cf2519..6a43bcaa0f 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -824,6 +824,10 @@ export default { 'settings.general.sessionSummaryContract.desc': '默认关闭。开启后会要求受支持的智能体在每轮结束时追加 AGENT_NOTIFY_SUMMARY 行,供通知和后台工作记录使用,对新开/恢复的会话生效。(已支持:Claude、Codex、OpenCode、远程 Grok;暂不支持:本地 Grok、Cursor)', 'settings.general.sessionSummaryInChat': '在聊天中显示状态摘要', 'settings.general.sessionSummaryInChat.desc': '只影响聊天和复制内容中的显示,不影响摘要生成、通知或后台工作记录。开启后显示状态行,关闭后隐藏;已存储消息保持不变。', + 'settings.general.peerTools.title': 'Peer 会话工具', + 'settings.general.peerTools.description': '控制智能体能否浏览或联系同一 hub 上的其他会话。', + 'settings.general.disablePeerTools': '禁用 peer 会话工具', + 'settings.general.disablePeerTools.desc': '默认关闭。开启后新会话中的智能体将看不到 list_peers / inspect_peer / ping_peer 三个工具,提示词指引同步移除,防止跨会话上下文污染。终端 hapi ping-peer 不受影响。可用每台机器的 HAPI_DISABLE_PEER_TOOLS 环境变量覆盖此值。', 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', diff --git a/web/src/routes/settings/general.tsx b/web/src/routes/settings/general.tsx index da7c5f6a7d..dfd4ae2018 100644 --- a/web/src/routes/settings/general.tsx +++ b/web/src/routes/settings/general.tsx @@ -42,7 +42,7 @@ export default function SettingsGeneralPage() { }) const hubSettingsMutation = useMutation({ - mutationFn: async (patch: { sessionSummaryContract?: boolean; sessionSummaryInChat?: boolean }) => { + mutationFn: async (patch: { sessionSummaryContract?: boolean; sessionSummaryInChat?: boolean; disablePeerTools?: boolean }) => { if (!api) throw new Error('API unavailable') return await api.updateHubSettings(patch) }, @@ -85,6 +85,24 @@ export default function SettingsGeneralPage() { ) : null} ) : null} + {isOwner ? ( + + {hubSettingsQuery.data ? ( + { + if (hubSettingsMutation.isPending) return + hubSettingsMutation.mutate({ disablePeerTools: checked }) + }} + /> + ) : null} + + ) : null}
diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index abaa297a46..d047dc421a 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -24,8 +24,8 @@ const { context, navigate, setAppearance, setColorTheme, setFontScale, setTermin setAppBadgeEnabled: vi.fn(), })) -const getHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }) -const updateHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: true, sessionSummaryInChat: false }) +const getHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false, disablePeerTools: false }) +const updateHubSettings = vi.fn().mockResolvedValue({ sessionSummaryContract: true, sessionSummaryInChat: false, disablePeerTools: false }) vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => ({ colorTheme: 'default', setColorTheme }), @@ -221,8 +221,8 @@ describe('responsive settings pages', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() - getHubSettings.mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false }) - updateHubSettings.mockResolvedValue({ sessionSummaryContract: true, sessionSummaryInChat: false }) + getHubSettings.mockResolvedValue({ sessionSummaryContract: false, sessionSummaryInChat: false, disablePeerTools: false }) + updateHubSettings.mockResolvedValue({ sessionSummaryContract: true, sessionSummaryInChat: false, disablePeerTools: false }) context.token = `x.${btoa(JSON.stringify({ ns: 'default' }))}.x` }) @@ -258,9 +258,10 @@ describe('responsive settings pages', () => { }) it('explains and keeps summary generation separate from chat display', async () => { - updateHubSettings.mockImplementation(async (patch: { sessionSummaryContract?: boolean; sessionSummaryInChat?: boolean }) => ({ + updateHubSettings.mockImplementation(async (patch: { sessionSummaryContract?: boolean; sessionSummaryInChat?: boolean; disablePeerTools?: boolean }) => ({ sessionSummaryContract: patch.sessionSummaryContract ?? false, sessionSummaryInChat: patch.sessionSummaryInChat ?? false, + disablePeerTools: patch.disablePeerTools ?? false, })) renderPage() @@ -283,6 +284,25 @@ describe('responsive settings pages', () => { }) }) + it('toggles the hub-wide peer-tools opt-out from the general page', async () => { + updateHubSettings.mockImplementation(async (patch: { sessionSummaryContract?: boolean; sessionSummaryInChat?: boolean; disablePeerTools?: boolean }) => ({ + sessionSummaryContract: patch.sessionSummaryContract ?? false, + sessionSummaryInChat: patch.sessionSummaryInChat ?? false, + disablePeerTools: patch.disablePeerTools ?? false, + })) + + renderPage() + + expect(await screen.findByRole('heading', { name: 'Peer session tools' })).toBeInTheDocument() + expect(screen.getByText('Control whether agents can browse or contact other sessions on this hub.')).toBeInTheDocument() + const toggle = await screen.findByRole('checkbox', { name: 'Disable peer session tools' }) + expect(toggle).not.toBeChecked() + fireEvent.click(toggle) + await waitFor(() => { + expect(updateHubSettings).toHaveBeenCalledWith({ disablePeerTools: true }) + }) + }) + it('renders compact display controls without dropdown popovers', () => { renderPage() expect(screen.getByRole('radio', { name: 'OLED Black' })).toBeInTheDocument() From 18ea5e6bad9808d86f19739353a42ee8ec40aee4 Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:50:11 +0800 Subject: [PATCH 08/14] docs: hub-wide peer-tools web toggle with env as per-runner override Document Settings -> General -> Peer session tools as the primary switch (hub-persisted, new sessions on every runner) and demote HAPI_DISABLE_PEER_TOOLS to a per-runner hard override, including the resolution order (non-empty env decides and fails safe on typos; else hub value; else tools available) and the terminal ping-peer exemption. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Code --- docs/guide/how-it-works.md | 2 +- docs/guide/installation.md | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index 06e1d39508..ae8de59641 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -36,7 +36,7 @@ hapi runner start # Run background service for remote session spawning hapi ping-peer --list # Shell peer shortlist (prefer MCP list_peers in-session) ``` -MCP peer tools (same hub/namespace as the session): `list_peers` (discover), `inspect_peer` (read), `ping_peer` (message). These work from runner-spawned sessions even when the hub is on another host - see [Installation → Split hub + remote runner](./installation.md#split-hub-remote-runner-peer-discovery). Runners that must keep agents out of other sessions' contexts can hide all three with `HAPI_DISABLE_PEER_TOOLS=1` - see [Installation → Disabling peer tools](./installation.md#disabling-peer-tools-on-a-runner). +MCP peer tools (same hub/namespace as the session): `list_peers` (discover), `inspect_peer` (read), `ping_peer` (message). These work from runner-spawned sessions even when the hub is on another host - see [Installation → Split hub + remote runner](./installation.md#split-hub-remote-runner-peer-discovery). Hub owners can hide all three hub-wide from Settings → General (applies to new sessions on every runner), and a runner can be pinned with `HAPI_DISABLE_PEER_TOOLS` regardless of the hub setting - see [Installation → Disabling peer tools](./installation.md#disabling-peer-tools). ### HAPI Hub diff --git a/docs/guide/installation.md b/docs/guide/installation.md index e9d75f3bec..fe5cc571f0 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -320,9 +320,13 @@ hapi runner start Session CLI may export an **explicit** non-default `HAPI_API_URL` (from env or settings) into child env so shell helpers hit the same remote hub. It does **not** mirror `CLI_API_TOKEN` into wrapped agents (settings/prompt-backed secrets stay out of agent env; a fresh `hapi` re-reads `~/.hapi/settings.json`, and systemd/env tokens already inherit). Prefer MCP `list_peers` inside a session. Web terminal PTYs still strip hub secrets. If `--list` fails with an auth/URL error, the message points at `hapi auth login` and the configured hub URL. -##### Disabling peer tools on a runner +##### Disabling peer tools -Set `HAPI_DISABLE_PEER_TOOLS=1` (also `true`/`yes`) in the runner environment to stop wrapped agents from browsing or messaging other HAPI sessions — for example when one agent digging through the session list and pinging peers pollutes other sessions' contexts. The switch is not advertised-then-refused: the `list_peers` / `inspect_peer` / `ping_peer` MCP tools are not registered on the session MCP server and not passed to `--allowedTools` / `--tools`, the peer steer block is dropped from agent system prompts (a pasted `/sessions/` citation only reminds the agent it is a hub path, not a local file), and the peer core refuses with an explicit `disabled` error as defense in depth — so `hapi ping-peer` / `hapi inspect-peer` from inside a wrapped agent shell fails loudly too. Scope the env to the runner (systemd unit, launchd plist, or runner guard script); operators who leave it unset in their interactive terminal keep full `hapi ping-peer` CLI access. +Hub owners can hide the peer tools hub-wide from the web UI: **Settings → General → Peer session tools → Disable peer session tools**. The preference is persisted on the hub, rides the session-creation / session-fetch responses, and applies to **new sessions on every runner** (running sessions keep their tool set until they end). No hub or runner restart is needed. + +For a per-runner hard override, set `HAPI_DISABLE_PEER_TOOLS` in the runner environment (systemd unit, launchd plist, or runner guard script) — for example when one agent digging through the session list and pinging peers pollutes other sessions' contexts. Resolution order: any non-empty env value decides for that runner (`0` / `false` / `off` / `no` → tools available; any other value, including typos, → disabled, so a kill switch fails safe); when the env is unset or empty, the hub toggle decides; default is tools available. Env wins over the hub value, so a pinned runner can always keep or kill the tools regardless of the web setting. + +In every disabled case the switch is not advertised-then-refused: the `list_peers` / `inspect_peer` / `ping_peer` MCP tools are not registered on the session MCP server and not passed to `--allowedTools` / `--tools`, the peer steer block is dropped from agent system prompts (a pasted `/sessions/` citation only reminds the agent it is a hub path, not a local file), and the peer core refuses with an explicit `disabled` error as defense in depth — so `hapi ping-peer` / `hapi inspect-peer` from inside a wrapped agent shell fails loudly too. The hub toggle never reaches an operator's interactive terminal (it only applies to bootstrapped agent sessions), so terminal `hapi ping-peer` keeps working there unless the env is exported globally. Additional runner commands: From 966eca8286c449418cfe1afb3367359a35f61559 Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:47:21 +0800 Subject: [PATCH 09/14] fix(cli): resolve peer-tools per session, carry it across child boundaries Review follow-up for the hub-wide peer-tools toggle (#2056): - Create-time snapshot: POST /cli/sessions responses carry disablePeerTools on the session row (SessionBootstrapInfo) and ApiSessionClient captures it, so registration, prompts, handlers, and child envs use this session's preference instead of the process-wide module value flipped by later bootstraps. - Child-process boundary: wrapped agents shelling hapi ping-peer spawn fresh processes with no module memory, so the resolved preference travels as an explicit env value. claude local/remote, codex local/shared/app-server (per-root shell_environment_policy), opencode, dsh, kimi, grok, copilot, and cursor all pin their session's value; a non-empty machine-level HAPI_DISABLE_PEER_TOOLS stays authoritative and the operator terminal keeps its env-only exemption. - Shared Codex prompts: sharedLaunchConfig no longer freezes the pre-bootstrap developer instructions into threadParams; each root resolves them post-bootstrap in config(), and thread params builders accept an env override used by the remote launcher. - MCP handlers pass their captured snapshot into the ping-peer core guard, keeping tools registered by an enabled session working after a later session flips the global switch. - Coverage: overlay matrix, cross-flip regressions for the MCP server and shared roots on opposite sides of a toggle change, spawn-env boundary tests for claude/codex children, and snapshot propagation through ApiSessionClient incl. lazy materialization. Co-Authored-By: Claude Code via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- cli/src/api/api.ts | 18 ++++- cli/src/api/apiSession.test.ts | 34 +++++++++ cli/src/api/apiSession.ts | 12 ++++ cli/src/claude/claudeLocal.test.ts | 46 +++++++++++- cli/src/claude/claudeLocal.ts | 13 +++- cli/src/claude/claudeLocalLauncher.ts | 1 + cli/src/claude/claudeRemote.ts | 13 +++- cli/src/claude/claudeRemoteLauncher.ts | 1 + cli/src/claude/sdk/query.ts | 3 +- cli/src/claude/sdk/types.ts | 7 ++ cli/src/claude/session.ts | 4 ++ cli/src/claude/utils/startHappyServer.test.ts | 45 ++++++++++++ cli/src/claude/utils/startHappyServer.ts | 14 ++-- cli/src/codex/codexAppServerClient.ts | 11 ++- cli/src/codex/codexLocal.test.ts | 71 +++++++++++++++++++ cli/src/codex/codexLocal.ts | 12 +++- cli/src/codex/codexLocalLauncher.ts | 2 + cli/src/codex/codexRemoteLauncher.ts | 20 ++++-- cli/src/codex/shared/launch.test.ts | 8 +++ cli/src/codex/shared/launch.ts | 8 ++- cli/src/codex/shared/root.test.ts | 53 +++++++++++++- cli/src/codex/shared/root.ts | 19 ++++- cli/src/codex/utils/appServerConfig.ts | 8 ++- cli/src/codex/utils/buildHapiMcpBridge.ts | 6 +- cli/src/copilot/copilotRemoteLauncher.ts | 3 +- cli/src/copilot/utils/copilotBackend.ts | 4 +- cli/src/cursor/cursorAcpRemoteLauncher.ts | 10 ++- cli/src/cursor/utils/cursorAcpBackend.ts | 4 +- cli/src/dsh/dshRemoteLauncher.ts | 3 +- cli/src/grok/grokRemoteLauncher.ts | 2 + cli/src/grok/utils/grokBackend.ts | 4 +- cli/src/kimi/kimiRemoteLauncher.ts | 3 +- cli/src/kimi/utils/kimiBackend.ts | 4 +- cli/src/modules/common/peerTools.test.ts | 52 +++++++++++++- cli/src/modules/common/peerTools.ts | 28 ++++++++ cli/src/modules/pingPeer/pingPeer.ts | 22 ++++-- cli/src/opencode/opencodeLocalLauncher.ts | 6 +- cli/src/opencode/opencodeRemoteLauncher.ts | 6 +- cli/src/opencode/utils/opencodeBackend.ts | 8 +++ 39 files changed, 537 insertions(+), 51 deletions(-) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index a1364824ae..be7e0baca5 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -23,6 +23,14 @@ import { ApiMachineClient } from './apiMachine' import { ApiSessionClient, type ApiSessionClientOptions } from './apiSession' import { buildHubRequestHeaders } from './hubExtraHeaders' +/** + * Session row plus the hub-level peer-tools preference captured from this + * session's own create response. The create-time snapshot is authoritative + * for everything derived from this session (tool registration, prompts, + * wrapped-agent child env); absent when an older hub does not send it. + */ +export type SessionBootstrapInfo = Session & { peerToolsDisabled?: boolean } + export class ApiClient { static async create(): Promise { return new ApiClient(getAuthToken()) @@ -54,7 +62,7 @@ export class ApiClient { } timeoutMs?: number signal?: AbortSignal - }): Promise { + }): Promise { const response = await axios.post( `${configuration.apiUrl}/cli/sessions`, { @@ -130,7 +138,13 @@ export class ApiClient { effort: raw.effort, serviceTier: raw.serviceTier, permissionMode: raw.permissionMode, - collaborationMode: raw.collaborationMode + collaborationMode: raw.collaborationMode, + // Create-time peer-tools snapshot rides the row so downstream + // session state (ApiSessionClient) captures its own preference + // instead of the process-wide module value. + ...(typeof parsed.data.disablePeerTools === 'boolean' + ? { peerToolsDisabled: parsed.data.disablePeerTools } + : {}) } } diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index 450d07b3cb..e5da8e09ff 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -170,6 +170,40 @@ function triggerIncomingUserMessage( }) } +describe('ApiSessionClient peer-tools snapshot (#2056)', () => { + it('captures the create-time preference from the bootstrap row', () => { + socketHarness.sockets.length = 0 + const client = new ApiSessionClient('token', { ...createSession(), peerToolsDisabled: true }) + expect(client.peerToolsDisabled).toBe(true) + client.close() + }) + + it('stays undefined when an older hub does not send the preference', () => { + socketHarness.sockets.length = 0 + const client = new ApiSessionClient('token', createSession()) + expect(client.peerToolsDisabled).toBeUndefined() + client.close() + }) + + it('adopts the snapshot when a pending session lazy-materializes', async () => { + socketHarness.sockets.length = 0 + const materialize = vi.fn(async () => createSession({ namespace: 'default' })) + const client = new ApiSessionClient('token', createSession(), { materialize }) + client.sendUserMessage('hello') + expect(await client.materialize()).toBe(true) + expect(client.peerToolsDisabled).toBeUndefined() + client.close() + + socketHarness.sockets.length = 0 + const materializeDisabled = vi.fn(async () => ({ ...createSession({ namespace: 'default' }), peerToolsDisabled: true })) + const adopted = new ApiSessionClient('token', createSession(), { materialize: materializeDisabled }) + adopted.sendUserMessage('hello') + expect(await adopted.materialize()).toBe(true) + expect(adopted.peerToolsDisabled).toBe(true) + adopted.close() + }) +}) + describe('ApiSessionClient lazy materialization', () => { it('does not connect or materialize without a real user message', async () => { socketHarness.sockets.length = 0 diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 619899d2ed..a49b220574 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -7,6 +7,7 @@ import type { ZodType } from 'zod' import { logger } from '@/ui/logger' import { backoff } from '@/utils/time' import { apiValidationError } from '@/utils/errorUtils' +import type { SessionBootstrapInfo } from './api' import { AsyncLock } from '@/utils/lock' import type { RawJSONLines } from '@/claude/types' import { configuration } from '@/configuration' @@ -243,6 +244,15 @@ export class ApiSessionClient extends EventEmitter { * the handler must read this synchronously at registration. */ hubArchived = false + /** + * Peer-tools kill switch captured from THIS session's create response + * (undefined when an older hub does not send it, or until a lazy pending + * session materializes). Authoritative for tool registration, prompts and + * wrapped-agent child env derived from this session; the module-wide + * preference only serves as fallback (#2056 review: keep the resolved + * preference scoped per session/root). + */ + peerToolsDisabled?: boolean private readonly token: string readonly sessionId: string private metadata: Metadata | null @@ -299,6 +309,7 @@ export class ApiSessionClient extends EventEmitter { this.metadataVersion = session.metadataVersion this.agentState = session.agentState this.agentStateVersion = session.agentStateVersion + this.peerToolsDisabled = (session as SessionBootstrapInfo).peerToolsDisabled this.materializer = options.materialize this.onMaterialized = options.onMaterialized this.state = this.materializer ? 'pending' : 'active' @@ -606,6 +617,7 @@ export class ApiSessionClient extends EventEmitter { this.metadataVersion = materialized.metadataVersion this.agentState = materialized.agentState this.agentStateVersion = materialized.agentStateVersion + this.peerToolsDisabled = (materialized as SessionBootstrapInfo).peerToolsDisabled ?? this.peerToolsDisabled this.state = 'active' if (shouldSyncMetadata && latestMetadata) { diff --git a/cli/src/claude/claudeLocal.test.ts b/cli/src/claude/claudeLocal.test.ts index 1ebca1d5e6..c1d5d52642 100644 --- a/cli/src/claude/claudeLocal.test.ts +++ b/cli/src/claude/claudeLocal.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const harness = vi.hoisted(() => ({ spawn: vi.fn(async (_opts: unknown) => {}) @@ -49,6 +49,7 @@ vi.mock('./sdk/utils', () => ({ })) import { claudeLocal } from './claudeLocal' +import { resetPeerToolsForTests } from '@/modules/common/peerTools' function getSpawnArgs(): string[] { const call = harness.spawn.mock.calls[0] @@ -113,3 +114,46 @@ describe('claudeLocal model arguments', () => { expect(args).toContain('--verbose') }) }) + +describe('claudeLocal peer-tools child env (#2056)', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS + + beforeEach(() => { + harness.spawn.mockClear() + }) + + afterEach(() => { + if (original === undefined) delete process.env.HAPI_DISABLE_PEER_TOOLS + else process.env.HAPI_DISABLE_PEER_TOOLS = original + resetPeerToolsForTests() + }) + + async function spawnEnv(peerToolsDisabled?: boolean): Promise> { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/workspace', + hookSettingsPath: '/tmp/hooks.json', + ...(peerToolsDisabled === undefined ? {} : { peerToolsDisabled }) + }) + const call = harness.spawn.mock.calls[0] + expect(call).toBeDefined() + return (call[0] as { env: Record }).env + } + + it('pins a disabled session snapshot as an explicit env value', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + expect((await spawnEnv(true)).HAPI_DISABLE_PEER_TOOLS).toBe('1') + }) + + it('pins an enabled session explicitly so a later hub flip cannot rewrite the child', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + expect((await spawnEnv(false)).HAPI_DISABLE_PEER_TOOLS).toBe('0') + }) + + it('keeps a machine-level override authoritative (no session overlay)', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + const env = await spawnEnv(false) + expect(env.HAPI_DISABLE_PEER_TOOLS).toBe('1') + }) +}) diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 43896bdb5d..3da90a9625 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -4,6 +4,7 @@ import { claudeCheckSession } from "./utils/claudeCheckSession"; import { getProjectPath } from "./utils/path"; import { appendMcpConfigArg } from "./utils/mcpConfig"; import { getSystemPrompt } from "./utils/systemPrompt"; +import { isPeerToolsDisabled, peerToolsEnvOverlay, withPeerToolsPreference } from '@/modules/common/peerTools'; import { withBunRuntimeEnv } from "@/utils/bunRuntime"; import { spawnWithTerminalGuard } from "@/utils/spawnWithTerminalGuard"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; @@ -37,6 +38,7 @@ export async function claudeLocal(opts: { mcpServers?: Record, path: string, claudeEnvVars?: Record, + peerToolsDisabled?: boolean claudeArgs?: string[] model?: SessionModel allowedTools?: string[] @@ -73,7 +75,10 @@ export async function claudeLocal(opts: { args.push('--resume', startFrom); } - args.push('--append-system-prompt', stripNewlinesForWindowsShellArg(getSystemPrompt())); + // Snapshot (env > this session's bootstrap) instead of the module-wide + // preference: a later session in this process must not rewrite our prompt. + const peerToolsDisabled = opts.peerToolsDisabled ?? isPeerToolsDisabled(); + args.push('--append-system-prompt', stripNewlinesForWindowsShellArg(getSystemPrompt(withPeerToolsPreference(peerToolsDisabled)))); const cleanupMcpConfig = appendMcpConfigArg(args, opts.mcpServers, { baseDir: projectDir @@ -114,6 +119,12 @@ export async function claudeLocal(opts: { const env = { ...cleanEnv, DISABLE_AUTOUPDATER: '1', + // Propagate the resolved peer-tools switch into the wrapped-agent env: + // shells spawning `hapi ping-peer` lose the wrapper's module memory, + // so the preference must travel as an explicit env value. Explicit + // claudeEnvVars still win; machine-level HAPI_DISABLE_PEER_TOOLS stays + // authoritative inside peerToolsEnvOverlay. + ...peerToolsEnvOverlay(peerToolsDisabled), ...opts.claudeEnvVars } diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index 7d199065d0..21a3536d15 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -61,6 +61,7 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | sessionId: session.sessionId, abort: abortSignal, claudeEnvVars: session.claudeEnvVars, + peerToolsDisabled: session.peerToolsDisabled, claudeArgs: session.claudeArgs, model: session.getModel(), mcpServers: session.mcpServers, diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index e2494ccc95..a41937dead 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -8,6 +8,7 @@ import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable"; import { getProjectPath } from "./utils/path"; import { awaitFileExist } from "@/modules/watcher/awaitFileExist"; import { getSystemPrompt } from "./utils/systemPrompt"; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; import { PermissionResult } from "./sdk/types"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { getDefaultClaudeCodePath } from "./sdk/utils"; @@ -20,6 +21,7 @@ export async function claudeRemote(opts: { path: string, mcpServers?: Record, claudeEnvVars?: Record, + peerToolsDisabled?: boolean, claudeArgs?: string[], allowedTools: string[], hookSettingsPath: string, @@ -157,8 +159,17 @@ export async function claudeRemote(opts: { // Prepare SDK options. For --fork-session, start query() before waiting for the // first child prompt so the native fork materializes at the clicked source state. - const hapiSystemPrompt = getSystemPrompt(); + // Snapshot (env > this session's bootstrap): the prompt must not flip when + // a later session in this process bootstraps with another preference. + const peerToolsDisabled = opts.peerToolsDisabled ?? isPeerToolsDisabled(); + const hapiSystemPrompt = getSystemPrompt(withPeerToolsPreference(peerToolsDisabled)); const sdkOptions: Options = { + // Propagate the resolved peer-tools switch onto the spawned Claude + // process so shells running `hapi ping-peer` resolve it without the + // wrapper's module memory. Machine-level env overrides stay + // authoritative inside peerToolsEnvOverlay; claudeEnvVars (applied to + // this process below) may still win explicitly. + env: withPeerToolsPreference(peerToolsDisabled), additionalArgs: filterCatalogAffectingClaudeArgs(opts.claudeArgs), cwd: opts.path, resume: startFrom ?? undefined, diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 4a99e556b4..690a68a8a5 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -487,6 +487,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { }, onThinkingChange: session.onThinkingChange, claudeEnvVars: session.claudeEnvVars, + peerToolsDisabled: session.peerToolsDisabled, claudeArgs: session.claudeArgs, onMessage, onFirstResult: (initialMessage) => { diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index f8d4334883..7791468fea 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -299,6 +299,7 @@ export function query(config: { options: { additionalArgs = [], additionalDirectories = [], + env, allowedTools = [], appendSystemPrompt, customSystemPrompt, @@ -382,7 +383,7 @@ export function query(config: { cleanupMcpConfig = appendMcpConfigArg(spawnArgs, mcpServers) // Spawn Claude Code process - const spawnEnv = withBunRuntimeEnv(process.env, { allowBunBeBun: false }) + const spawnEnv = withBunRuntimeEnv(env ?? process.env, { allowBunBeBun: false }) logDebug(`Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(' ')}`) const child = spawn(spawnCommand, spawnArgs, { diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index a05bbf6694..2916d66e43 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -183,6 +183,13 @@ export interface CanCallToolCallback { export interface QueryOptions { abort?: AbortSignal additionalArgs?: string[] + /** + * Base environment for the spawned Claude process (defaults to + * process.env). Lets a wrapped session pin env values — e.g. the resolved + * peer-tools switch — onto its own child without mutating the runner's + * process-wide env. + */ + env?: Record additionalDirectories?: string[] allowedTools?: string[] appendSystemPrompt?: string diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index f0cc938bd7..648da1e32c 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -1,4 +1,5 @@ import { LocalPermissionBridge } from './utils/localPermissionBridge'; +import { isPeerToolsDisabled } from '@/modules/common/peerTools'; import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { logger } from '@/ui/logger'; @@ -15,6 +16,8 @@ type LocalLaunchFailure = { export class Session extends AgentSessionBase { readonly claudeEnvVars?: Record; + /** Peer-tools switch captured from this session's bootstrap (env > hub). */ + readonly peerToolsDisabled: boolean; claudeArgs?: string[]; readonly mcpServers: Record; readonly allowedTools?: string[]; @@ -69,6 +72,7 @@ export class Session extends AgentSessionBase { }); this.claudeEnvVars = opts.claudeEnvVars; + this.peerToolsDisabled = opts.client.peerToolsDisabled ?? isPeerToolsDisabled(); this.claudeArgs = opts.claudeArgs; this.mcpServers = opts.mcpServers; this.allowedTools = opts.allowedTools; diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index c1f39c152f..e06e392c66 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -8,6 +8,13 @@ import type { ApiSessionClient } from '@/api/apiSession' import { applyHubPeerToolsDisabled, resetPeerToolsForTests } from '@/modules/common/peerTools' import { startHappyServer, toClaudeAllowedHapiMcpTools } from './startHappyServer' +// The ping_peer core is stubbed so the cross-flip regression can observe the +// snapshot handed to the handler without network access. +vi.mock('@/modules/pingPeer/pingPeer', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, pingPeer: vi.fn(actual.pingPeer) } +}) + type ToolResult = { content?: Array<{ type: string; text?: string }> isError?: boolean @@ -394,4 +401,42 @@ describe('startHappyServer peer tools kill switch', () => { expect(names).not.toContain('ping_peer') expect(names).not.toContain('inspect_peer') }) + + it('keeps handlers registered by an enabled session working after a later session flips the global switch (#2056)', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + const { pingPeer } = await import('@/modules/pingPeer/pingPeer') + vi.mocked(pingPeer).mockRejectedValueOnce(new Error('offline in test')) + + // Session A bootstraps with peer tools enabled (create-time snapshot). + const sessionClient = { + peerToolsDisabled: false, + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + sendClaudeSessionMessage: vi.fn() + } as unknown as ApiSessionClient + const server = await startHappyServer(sessionClient, {}) + stopServer = server.stop + client = new Client( + { name: 'hapi-peer-flip-test', version: '1.0.0' }, + { capabilities: {} } + ) + await client.connect(new StreamableHTTPClientTransport(new URL(server.url))) + + // Session B bootstraps elsewhere in the same process and flips the + // module-wide preference. A's registered handlers must keep working. + applyHubPeerToolsDisabled(true) + + const result = await client.callTool({ + name: 'ping_peer', + arguments: { sessionIdPrefix: 'abc123', message: 'hello' } + }) as ToolResult + expect(result.isError).toBe(true) + expect(JSON.stringify(result.content)).not.toContain('peer session tools are disabled') + // The handler resolved the guard from A's captured snapshot, not the + // flipped module state. + expect(pingPeer).toHaveBeenCalledWith(expect.objectContaining({ + sessionIdPrefix: 'abc123', + peerToolsDisabled: false + })) + }) }) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index e3814f776d..c3dae8ede3 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -122,9 +122,12 @@ function createHapiMcpServer( const maxInlineMediaBytes = 25 * 1024 * 1024; - // HAPI_DISABLE_PEER_TOOLS=1: peer tools are neither registered nor - // advertised, so wrapped agents cannot browse or message other sessions. - const peerToolsDisabled = isPeerToolsDisabled(); + // HAPI_DISABLE_PEER_TOOLS=1 (or the hub toggle): peer tools are neither + // registered nor advertised, so wrapped agents cannot browse or message + // other sessions. Prefer this session's create-time snapshot over the + // module-wide preference: one runner process hosts many sessions, and a + // later bootstrap must not flip an already-running session (new-session-only). + const peerToolsDisabled = client.peerToolsDisabled ?? isPeerToolsDisabled(); const inspectPeerInputSchema: z.ZodTypeAny = z.object({ sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION), @@ -314,6 +317,7 @@ function createHapiMcpServer( const result = await pingPeer({ sessionIdPrefix: args.sessionIdPrefix, message: args.message, + peerToolsDisabled, }); return { content: [ @@ -353,6 +357,7 @@ function createHapiMcpServer( const result = await inspectPeer({ sessionIdPrefix: args.sessionIdPrefix, messageLimit: args.messageLimit, + peerToolsDisabled, }); return { content: [ @@ -392,6 +397,7 @@ function createHapiMcpServer( const limit = args.limit ?? 30; const sessions = await listPeerSessions({ limit: peerListFetchLimit(limit, { excludeCaller: true }), + peerToolsDisabled, }); const peers = sessions.filter((session) => session.id !== client.sessionId); const hasMore = peers.length > limit; @@ -547,7 +553,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH hapiMcpUrl: mcpUrl, })); - const peerToolsDisabled = isPeerToolsDisabled(); + const peerToolsDisabled = client.peerToolsDisabled ?? isPeerToolsDisabled(); const toolNames = [ ...(enableChangeTitle ? ['change_title'] : []), 'display_image', diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index 3460ffa62d..b7c9b6c671 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -82,6 +82,12 @@ export function isIndeterminateError(error: unknown): boolean { type CodexAppServerClientOptions = { cwd?: string; + /** + * Base env for the spawned `codex app-server` child; defaults to + * process.env. Callers pass the session-pinned peer-tools overlay so + * shell commands inside the app-server resolve the switch consistently. + */ + env?: NodeJS.ProcessEnv; /** An independently owned shared server. Disconnect only detaches this client. */ endpoint?: string; token?: string; @@ -256,10 +262,11 @@ export class CodexAppServerClient extends JsonLineParser { const codexCommand = resolveCodexAppServerCommand(); logger.debug(`[CodexAppServer] Starting ${codexCommand} app-server`); + const spawnEnv = this.options.env ?? process.env; const child = spawn(codexCommand, ['app-server'], { cwd: this.options.cwd, - env: Object.keys(process.env).reduce((acc, key) => { - const value = process.env[key]; + env: Object.keys(spawnEnv).reduce((acc, key) => { + const value = spawnEnv[key]; if (typeof value === 'string') acc[key] = value; return acc; }, {} as Record), diff --git a/cli/src/codex/codexLocal.test.ts b/cli/src/codex/codexLocal.test.ts index d4608d5ee2..f1c3b22819 100644 --- a/cli/src/codex/codexLocal.test.ts +++ b/cli/src/codex/codexLocal.test.ts @@ -148,6 +148,77 @@ describe('codexLocal', () => { expect(args).toContain('mcp_servers.hapi.tools.change_title.approval_mode="approve"'); }); + it('pins the session peer-tools snapshot into the child env and prompt (#2056)', async () => { + const controller = new AbortController(); + + await codexLocal({ + abort: controller.signal, + sessionId: null, + path: workspacePath, + onSessionFound: vi.fn(), + peerToolsDisabled: true + }); + + expect(spawnWithTerminalGuardMock).toHaveBeenCalledOnce(); + const spawnOptions = spawnWithTerminalGuardMock.mock.calls[0][0] as { + env: Record; + args: string[]; + }; + // A fresh `hapi ping-peer` in the agent's shell resolves the pinned + // value, not the wrapper process's module memory. + expect(spawnOptions.env.HAPI_DISABLE_PEER_TOOLS).toBe('1'); + const instructionsArg = spawnOptions.args.find((arg) => arg.startsWith('developer_instructions=')) as string; + expect(instructionsArg).toBeDefined(); + expect(instructionsArg).toContain('are disabled on this machine'); + expect(instructionsArg).not.toContain('hapi__ping_peer'); + }); + + it('pins enabled sessions explicitly so a later global flip cannot rewrite their child (#2056)', async () => { + const controller = new AbortController(); + + await codexLocal({ + abort: controller.signal, + sessionId: null, + path: workspacePath, + onSessionFound: vi.fn(), + peerToolsDisabled: false + }); + + const spawnOptions = spawnWithTerminalGuardMock.mock.calls[0][0] as { + env: Record; + args: string[]; + }; + expect(spawnOptions.env.HAPI_DISABLE_PEER_TOOLS).toBe('0'); + const instructionsArg = spawnOptions.args.find((arg) => arg.startsWith('developer_instructions=')) as string; + expect(instructionsArg).toContain('hapi__ping_peer'); + }); + + it('keeps a machine-level env override authoritative over the session snapshot (#2056)', async () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS; + process.env.HAPI_DISABLE_PEER_TOOLS = '1'; + try { + const controller = new AbortController(); + await codexLocal({ + abort: controller.signal, + sessionId: null, + path: workspacePath, + onSessionFound: vi.fn(), + peerToolsDisabled: false + }); + const spawnOptions = spawnWithTerminalGuardMock.mock.calls[0][0] as { + env: Record; + args: string[]; + }; + // No session overlay: the deploy-time env inherits unchanged. + expect(spawnOptions.env.HAPI_DISABLE_PEER_TOOLS).toBe('1'); + const instructionsArg = spawnOptions.args.find((arg) => arg.startsWith('developer_instructions=')) as string; + expect(instructionsArg).not.toContain('hapi__ping_peer'); + } finally { + if (original === undefined) delete process.env.HAPI_DISABLE_PEER_TOOLS; + else process.env.HAPI_DISABLE_PEER_TOOLS = original; + } + }); + it('passes reasoning effort through Codex config instead of an unsupported CLI flag', async () => { const controller = new AbortController(); diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index 91d8c4d6ee..16ed68b90a 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -1,5 +1,6 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; import { buildMcpServerConfigArgs, buildDeveloperInstructionsArg, @@ -118,6 +119,8 @@ export async function codexLocal(opts: { sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; onSessionFound: (id: string) => void; codexArgs?: string[]; + /** Session peer-tools snapshot; pins the spawned Codex env and prompt. */ + peerToolsDisabled?: boolean; mcpServers?: McpServersConfig; sessionHook?: { port: number; @@ -152,8 +155,11 @@ export async function codexLocal(opts: { args.push(...buildCodexHookConfigArgs(opts.sessionHook.port, opts.sessionHook.token)); } - // Add developer instructions (system prompt) - args.push(...buildDeveloperInstructionsArg(getCodexSystemPrompt())); + // Add developer instructions (system prompt). Pin the session snapshot + // (env > this session's bootstrap) into both the prompt and the child env + // so a later bootstrap in the same runner process cannot flip this child. + const peerEnv = withPeerToolsPreference(opts.peerToolsDisabled ?? isPeerToolsDisabled()); + args.push(...buildDeveloperInstructionsArg(getCodexSystemPrompt(peerEnv))); if (opts.codexArgs) { // Before the first launch, Codex still needs the user's selector (for @@ -178,7 +184,7 @@ export async function codexLocal(opts: { command: codexCommand.command, args: [...codexCommand.args, ...args], cwd: opts.path, - env: process.env, + env: peerEnv, signal: opts.abort, logLabel: 'CodexLocal', spawnName: 'codex', diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index e50f43d6d5..f24b1550a9 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -2,6 +2,7 @@ import { logger } from '@/ui/logger'; import { resolve } from 'node:path'; import { startHookServer } from '@/claude/utils/startHookServer'; import { codexLocal } from './codexLocal'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; import type { ReasoningEffort } from './appServerTypes'; import { CodexSession } from './session'; import { createCodexSessionScanner, type CodexSessionScanner } from './utils/codexSessionScanner'; @@ -445,6 +446,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch await codexLocal({ path: session.path, sessionId: resumeSessionId, + peerToolsDisabled: session.client.peerToolsDisabled ?? isPeerToolsDisabled(), modelReasoningEffort: (session.getModelReasoningEffort() ?? undefined) as ReasoningEffort | undefined, onSessionFound: handleSessionFound, abort: abortSignal, diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index b34899bac3..d866a9324d 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -16,6 +16,7 @@ import type { QueueReservation } from '@/utils/MessageQueue2'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; import { AppServerEventConverter } from './utils/appServerEventConverter'; import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, @@ -252,7 +253,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase { constructor(session: CodexSession) { super(process.env.DEBUG ? session.logPath : undefined); this.session = session; - this.appServerClient = new CodexAppServerClient(); + this.appServerClient = new CodexAppServerClient({ + // Session-pinned peer-tools overlay: the app-server child resolves + // the switch from this session's create-time snapshot, not the + // runner process's latest module state. + env: withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled()) + }); } protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { @@ -3792,7 +3798,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { mode, mcpServers, cliOverrides: session.codexCliOverrides, - contextManagementConfig + contextManagementConfig, + env: withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled()) }); try { @@ -3858,7 +3865,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { mode, mcpServers, cliOverrides: session.codexCliOverrides, - contextManagementConfig + contextManagementConfig, + env: withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled()) }); try { const resumeResponse = await appServerClient.resumeThread({ @@ -3890,7 +3898,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { mode, mcpServers, cliOverrides: session.codexCliOverrides, - contextManagementConfig + contextManagementConfig, + env: withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled()) }); const threadResponse = await appServerClient.startThread({ ...threadParams, @@ -4142,7 +4151,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { mode: message.mode, mcpServers, cliOverrides: session.codexCliOverrides, - contextManagementConfig + contextManagementConfig, + env: withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled()) }); const resumeCandidate = session.sessionId ?? null; diff --git a/cli/src/codex/shared/launch.test.ts b/cli/src/codex/shared/launch.test.ts index 82bfd560ef..c414c02374 100644 --- a/cli/src/codex/shared/launch.test.ts +++ b/cli/src/codex/shared/launch.test.ts @@ -47,6 +47,14 @@ describe('shared launch configuration', () => { expect(sharedLaunchConfig({ codexArgs: ['--', '-C', 'literal'] }, '/tmp').tuiArgs).toEqual(['--', '-C', 'literal']); expect(() => sharedLaunchConfig({ permissionMode: 'safe-yolo' }, '/tmp')).toThrow('not a native shared'); }); + + it('does not freeze developer instructions into threadParams (#2056)', () => { + // sharedLaunchConfig runs before the hub bootstrap delivers the + // peer-tools preference; each root resolves its prompt post-bootstrap + // in SharedCodexRoot.config() instead of carrying a frozen string. + const result = sharedLaunchConfig({}, '/tmp'); + expect(result.threadParams).not.toHaveProperty('developerInstructions'); + }); it('preserves launch-time provider and automatic-review policy', () => { const result = sharedLaunchConfig({ codexArgs: ['--oss', '--local-provider', 'ollama', '--approve-for-me'] }, '/tmp'); expect(result.threadParams).toMatchObject({ modelProvider: 'ollama', approvalsReviewer: 'auto_review', sandbox: 'workspace-write' }); diff --git a/cli/src/codex/shared/launch.ts b/cli/src/codex/shared/launch.ts index 71b8e934d6..a521037fd8 100644 --- a/cli/src/codex/shared/launch.ts +++ b/cli/src/codex/shared/launch.ts @@ -4,7 +4,6 @@ import { z } from 'zod'; import { resolveCodexCommand, type CodexCommand } from '../utils/codexExecutable'; import { parseCodexCliOverrides, stripCodexCliOverrides } from '../utils/codexCliOverrides'; import { resolveCodexPermissionModeConfig } from '../utils/permissionModeConfig'; -import { getCodexSystemPrompt } from '../utils/systemPrompt'; import type { CodexAppServerClient } from '../codexAppServerClient'; export const SharedLaunchSchema = z.object({ @@ -135,6 +134,10 @@ export function sharedLaunchConfig(options: SharedLaunchOptions, cwd: string): { tuiArgs = stripCodexCliOverrides(tuiArgs); // Launch-wide flags also apply to later native /new roots, not just the // initial HAPI-created root. Explicit config layers keep their order. + // developerInstructions is deliberately absent: sharedLaunchConfig runs + // before the hub bootstrap delivers the peer-tools preference, so each + // root resolves its prompt at request time in SharedCodexRoot.config() + // (post-bootstrap, per-root snapshot). for (const [key, value] of Object.entries(config)) serverArgs.push('-c', `${key}=${JSON.stringify(value)}`); return { cwd: directory, serverArgs, tuiArgs, @@ -146,8 +149,7 @@ export function sharedLaunchConfig(options: SharedLaunchOptions, cwd: string): { ...(overrides.sandbox ? { sandbox: overrides.sandbox } : {}), ...(autoReview ? { approvalPolicy: 'on-request', approvalsReviewer: 'auto_review', sandbox: 'workspace-write' } : {}), ...(permission ? { approvalPolicy: permission.approvalPolicy, approvalsReviewer: 'user', sandbox: permission.sandbox } : {}), - config: { ...config, ...(options.modelReasoningEffort ? { model_reasoning_effort: options.modelReasoningEffort } : {}) }, - developerInstructions: getCodexSystemPrompt() + config: { ...config, ...(options.modelReasoningEffort ? { model_reasoning_effort: options.modelReasoningEffort } : {}) } } }; } diff --git a/cli/src/codex/shared/root.test.ts b/cli/src/codex/shared/root.test.ts index ae0276420d..a0962d11ed 100644 --- a/cli/src/codex/shared/root.test.ts +++ b/cli/src/codex/shared/root.test.ts @@ -6,6 +6,8 @@ import type { SessionBootstrapResult } from '@/agent/sessionFactory'; import { SharedCodexRoot, type RootHost } from './root'; import { codexPlanProposalId } from './plan'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import { SESSION_CITATION_DISABLED_STEER } from '@hapi/protocol/sessionCitation'; +import { applyHubPeerToolsDisabled, resetPeerToolsForTests } from '@/modules/common/peerTools'; type NativeTurn = { id: string; status: string; items: unknown[] }; @@ -53,7 +55,7 @@ afterEach(async () => { finally { vi.useRealTimers(); } }); -async function fixture(opts?: { hubArchived?: boolean; end?: RootHost['end'] }) { +async function fixture(opts?: { hubArchived?: boolean; end?: RootHost['end']; peerToolsDisabled?: boolean }) { const directory = await mkdtemp('/tmp/hapi-shared-root-'); let state: AgentState = { steeringActive: true }; let metadata: Metadata = { path: directory, host: 'test', flavor: 'codex' }; @@ -65,6 +67,7 @@ async function fixture(opts?: { hubArchived?: boolean; end?: RootHost['end'] }) const session = { sessionId: 'sid', getMetadata: () => metadata, hubArchived: opts?.hubArchived ?? false, + peerToolsDisabled: opts?.peerToolsDisabled, updateMetadata: (fn: (value: Metadata) => Metadata) => { metadata = fn(metadata); }, updateAgentState: updateState, keepAlive() {}, onUserMessage() {}, onCancelQueuedMessage() {}, onRetryQueuedMessage() {}, @@ -322,3 +325,51 @@ describe('shared steering availability', () => { await vi.waitFor(() => expect(end).toHaveBeenCalledTimes(1)); }); }); + +describe('shared root peer-tools snapshot (create-time capture wins)', () => { + const originalEnv = process.env.HAPI_DISABLE_PEER_TOOLS; + + afterEach(() => { + if (originalEnv === undefined) delete process.env.HAPI_DISABLE_PEER_TOOLS; + else process.env.HAPI_DISABLE_PEER_TOOLS = originalEnv; + resetPeerToolsForTests(); + }); + + it('keeps a root created before a toggle flip advertising peer tools', async () => { + const enabled = await fixture({ peerToolsDisabled: false }); + const before = enabled.root.config({}) as Record; + expect(before.developerInstructions).toContain('hapi__ping_peer'); + expect((before.config as Record)['shell_environment_policy.set.HAPI_DISABLE_PEER_TOOLS']).toBe('0'); + + // Another session bootstraps with the switch on: the module global + // flips, but this root's prompt and shell env stay pinned to its own + // create-time snapshot. + applyHubPeerToolsDisabled(true); + const after = enabled.root.config({}) as Record; + expect(after.developerInstructions).toContain('hapi__ping_peer'); + expect((after.config as Record)['shell_environment_policy.set.HAPI_DISABLE_PEER_TOOLS']).toBe('0'); + }); + + it('pins a root created after the flip to disabled even when the switch turns back off', async () => { + applyHubPeerToolsDisabled(true); + const disabled = await fixture(); + const before = disabled.root.config({}) as Record; + expect(before.developerInstructions).toContain(SESSION_CITATION_DISABLED_STEER); + expect(before.developerInstructions).not.toContain('hapi__ping_peer'); + expect((before.config as Record)['shell_environment_policy.set.HAPI_DISABLE_PEER_TOOLS']).toBe('1'); + + applyHubPeerToolsDisabled(false); + const after = disabled.root.config({}) as Record; + expect(after.developerInstructions).toContain(SESSION_CITATION_DISABLED_STEER); + expect((after.config as Record)['shell_environment_policy.set.HAPI_DISABLE_PEER_TOOLS']).toBe('1'); + }); + + it('keeps a machine-level env override authoritative over the session snapshot', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1'; + const enabled = await fixture({ peerToolsDisabled: false }); + const config = enabled.root.config({}) as Record; + // No overlay key: the deploy-time env inherits into children as-is. + expect((config.config as Record)).not.toHaveProperty('shell_environment_policy.set.HAPI_DISABLE_PEER_TOOLS'); + expect(config.developerInstructions).toContain(SESSION_CITATION_DISABLED_STEER); + }); +}); diff --git a/cli/src/codex/shared/root.ts b/cli/src/codex/shared/root.ts index 187c015346..1811cbe8db 100644 --- a/cli/src/codex/shared/root.ts +++ b/cli/src/codex/shared/root.ts @@ -19,6 +19,7 @@ import { SharedCodexPermissions } from './permissions'; import { SharedCodexQueue } from './queue'; import { SharedCodexProjection, inputText } from './projection'; import { getCodexSystemPrompt } from '../utils/systemPrompt'; +import { isPeerToolsDisabled, peerToolsEnvOverlay, withPeerToolsPreference } from '@/modules/common/peerTools'; import { record, string } from './gateway'; import { initializeSharedClient, type SharedLaunchOptions } from './launch'; import { inheritedSandbox, settingsMatch } from './settings'; @@ -67,6 +68,8 @@ export class SharedCodexRoot { private closing?: Promise; private readonly controls = new Set>(); private reconnecting = false; + /** Peer-tools switch captured from this root's own bootstrap (env > hub). */ + private readonly peerToolsDisabled: boolean; private settings: RuntimeSettings = {}; private settingsNative: Record = {}; private settingsNotification?: Record; @@ -76,6 +79,9 @@ export class SharedCodexRoot { constructor(readonly bootstrap: SessionBootstrapResult, private readonly host: RootHost) { this.session = bootstrap.session; + // Frozen at construction (right after this root's bootstrap): later + // roots in the same process must not flip this root's tools or prompt. + this.peerToolsDisabled = bootstrap.session.peerToolsDisabled ?? isPeerToolsDisabled(); this.client = new CodexAppServerClient({ endpoint: host.endpoint, token: host.token, cwd: bootstrap.workingDirectory }); this.client.setNotificationHandler((method, params) => { if (method === 'serverRequest/resolved') { @@ -176,10 +182,18 @@ export class SharedCodexRoot { return false; } config(params: Record): Record { + // Propagate the resolved peer-tools switch into this root's shell env: + // `hapi ping-peer` spawned by the agent resolves the captured value + // instead of the wrapper process's module memory. Machine-level env + // overrides stay authoritative (peerToolsEnvOverlay yields none), and + // the operator terminal never runs through a shared root. + const peerShellEnv = Object.fromEntries(Object.entries(peerToolsEnvOverlay(this.peerToolsDisabled)) + .map(([name, value]) => [`shell_environment_policy.set.${name}`, value])); return { ...params, cwd: params.cwd ?? this.bootstrap.workingDirectory, - developerInstructions: params.developerInstructions ?? getCodexSystemPrompt(), config: { + developerInstructions: params.developerInstructions ?? getCodexSystemPrompt(withPeerToolsPreference(this.peerToolsDisabled)), config: { ...record(params.config), 'mcp_servers.hapi': this.bridge.mcpServers.hapi, - 'shell_environment_policy.set.HAPI_SESSION_ID': this.session.sessionId + 'shell_environment_policy.set.HAPI_SESSION_ID': this.session.sessionId, + ...peerShellEnv } }; } async bind(threadId: string, response: Record, subscribe: boolean): Promise { @@ -520,7 +534,6 @@ export class SharedCodexRoot { return { ...sandbox, cwd: this.settingsNative.cwd ?? this.bootstrap.workingDirectory, model: this.settingsNative.model, modelProvider: this.settingsNative.modelProvider, approvalPolicy: this.settingsNative.approvalPolicy, serviceTier: this.settingsNative.serviceTier, approvalsReviewer: this.settingsNative.approvalsReviewer, personality: this.settingsNative.personality, - developerInstructions: getCodexSystemPrompt(), config: { ...record(sandbox.config), model_reasoning_effort: this.settings.modelReasoningEffort ?? undefined } }; } private notice(message: string): void { this.session.sendSessionEvent({ type: 'message', message }); } diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index fcd7051099..6a6d981f79 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -115,9 +115,11 @@ function buildMcpServerConfig(mcpServers: CodexMcpServersConfig): Record { - const backend = createDshBackend() + const backend = createDshBackend(withPeerToolsPreference(this.session.client.peerToolsDisabled ?? isPeerToolsDisabled())) this.backend = backend backend.onStderrError((error) => { logger.debug('[dsh-acp] stderr error', error) diff --git a/cli/src/grok/grokRemoteLauncher.ts b/cli/src/grok/grokRemoteLauncher.ts index fe591848b9..a9b5a5830a 100644 --- a/cli/src/grok/grokRemoteLauncher.ts +++ b/cli/src/grok/grokRemoteLauncher.ts @@ -22,6 +22,7 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import { getGrokTitleInstruction } from './utils/systemPrompt' import { GrokConversationHistory } from './conversationHistory' import { isObject } from '@hapi/protocol' +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools' const PLAN_MODE_INSTRUCTION = 'Work in plan-only mode. Analyze and propose a plan, but do not execute commands or modify files.' @@ -77,6 +78,7 @@ class GrokRemoteLauncher extends RemoteLauncherBase { const backend = createGrokBackend({ cwd: session.path, + env: withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled()), ...this.opts }) this.backend = backend diff --git a/cli/src/grok/utils/grokBackend.ts b/cli/src/grok/utils/grokBackend.ts index 2bbf02f207..cd322328d3 100644 --- a/cli/src/grok/utils/grokBackend.ts +++ b/cli/src/grok/utils/grokBackend.ts @@ -41,11 +41,13 @@ export function createGrokBackend(opts: { cwd: string model?: string effort?: string + /** Base env for the ACP child; defaults to process.env. */ + env?: NodeJS.ProcessEnv }): AcpSdkBackend { return new AcpSdkBackend({ command: getAgentLaunchCommand('grok'), args: buildGrokAgentArgs(opts), - env: filterEnv(process.env), + env: filterEnv(opts.env ?? process.env), // Grok ACP emits true agent_message_chunk deltas. Default overlap // dedupe treats a later "0" after "300" as an already-buffered suffix // and drops it, so the hub stores 300 instead of 3000. diff --git a/cli/src/kimi/kimiRemoteLauncher.ts b/cli/src/kimi/kimiRemoteLauncher.ts index f0c4f35fe2..903646aec4 100644 --- a/cli/src/kimi/kimiRemoteLauncher.ts +++ b/cli/src/kimi/kimiRemoteLauncher.ts @@ -2,6 +2,7 @@ import React from 'react'; import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle'; import { logger } from '@/ui/logger'; import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; import { convertAgentMessage } from '@/agent/messageConverter'; import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; @@ -52,7 +53,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase { const runtimeConfig = resolveKimiRuntimeConfig({ model: this.model }); - const backend = createKimiBackend(); + const backend = createKimiBackend(withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled())); this.backend = backend; registerAcpSessionTitleSync(backend, session.client); diff --git a/cli/src/kimi/utils/kimiBackend.ts b/cli/src/kimi/utils/kimiBackend.ts index 8dd681eaf4..e57a5395be 100644 --- a/cli/src/kimi/utils/kimiBackend.ts +++ b/cli/src/kimi/utils/kimiBackend.ts @@ -18,11 +18,11 @@ function filterEnv(env: NodeJS.ProcessEnv): Record { * is applied over ACP (`session/set_model` / `session/set_config_option`) * after session creation — see kimiRemoteLauncher. */ -export function createKimiBackend(): AcpSdkBackend { +export function createKimiBackend(env: NodeJS.ProcessEnv = process.env): AcpSdkBackend { return new AcpSdkBackend({ command: getAgentLaunchCommand('kimi'), args: ['acp'], - env: filterEnv(process.env), + env: filterEnv(env), flavor: 'kimi', }); } diff --git a/cli/src/modules/common/peerTools.test.ts b/cli/src/modules/common/peerTools.test.ts index 33dc7a76ac..7d9c7ff9a5 100644 --- a/cli/src/modules/common/peerTools.test.ts +++ b/cli/src/modules/common/peerTools.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { applyHubPeerToolsDisabled, isPeerToolsDisabled, - resetPeerToolsForTests + peerToolsEnvOverlay, + resetPeerToolsForTests, + withPeerToolsPreference } from './peerTools' describe('isPeerToolsDisabled', () => { @@ -65,3 +67,51 @@ describe('isPeerToolsDisabled', () => { expect(isPeerToolsDisabled()).toBe(false) }) }) + +describe('peerToolsEnvOverlay', () => { + it('pins the session preference as an explicit env value when the machine env is unset', () => { + expect(peerToolsEnvOverlay(true, {} as NodeJS.ProcessEnv)).toEqual({ HAPI_DISABLE_PEER_TOOLS: '1' }) + expect(peerToolsEnvOverlay(false, {} as NodeJS.ProcessEnv)).toEqual({ HAPI_DISABLE_PEER_TOOLS: '0' }) + }) + + it('treats an empty machine env as unset (session preference still pins)', () => { + expect(peerToolsEnvOverlay(true, { HAPI_DISABLE_PEER_TOOLS: '' } as NodeJS.ProcessEnv)) + .toEqual({ HAPI_DISABLE_PEER_TOOLS: '1' }) + }) + + it('keeps a non-empty machine env authoritative (deploy-time override inherits, no overlay)', () => { + for (const machineValue of ['1', '0', 'banana']) { + expect(peerToolsEnvOverlay(true, { HAPI_DISABLE_PEER_TOOLS: machineValue } as NodeJS.ProcessEnv)) + .toEqual({}) + expect(peerToolsEnvOverlay(false, { HAPI_DISABLE_PEER_TOOLS: machineValue } as NodeJS.ProcessEnv)) + .toEqual({}) + } + }) +}) + +describe('withPeerToolsPreference', () => { + it('produces an env that resolves to the session preference from a fresh process', () => { + // A wrapped agent shelling `hapi ping-peer` starts with no module + // memory; the pinned env alone must carry the resolution. + expect(isPeerToolsDisabled(withPeerToolsPreference(true, {} as NodeJS.ProcessEnv))).toBe(true) + expect(isPeerToolsDisabled(withPeerToolsPreference(false, {} as NodeJS.ProcessEnv))).toBe(false) + }) + + it('pins enabled sessions so a later global flip cannot rewrite their children', () => { + const childEnv = withPeerToolsPreference(false, {} as NodeJS.ProcessEnv) + applyHubPeerToolsDisabled(true) + expect(isPeerToolsDisabled(childEnv)).toBe(false) + }) + + it('lets a machine-level override beat the session snapshot (explicit env wins)', () => { + expect(isPeerToolsDisabled(withPeerToolsPreference(true, { HAPI_DISABLE_PEER_TOOLS: '0' } as NodeJS.ProcessEnv))).toBe(false) + expect(isPeerToolsDisabled(withPeerToolsPreference(false, { HAPI_DISABLE_PEER_TOOLS: '1' } as NodeJS.ProcessEnv))).toBe(true) + }) + + it('merges the overlay without mutating the source env', () => { + const source: NodeJS.ProcessEnv = { PATH: '/usr/bin' } + const merged = withPeerToolsPreference(true, source) + expect(merged).toEqual({ PATH: '/usr/bin', HAPI_DISABLE_PEER_TOOLS: '1' }) + expect(source).toEqual({ PATH: '/usr/bin' }) + }) +}) diff --git a/cli/src/modules/common/peerTools.ts b/cli/src/modules/common/peerTools.ts index 75dd0e1048..58bead633b 100644 --- a/cli/src/modules/common/peerTools.ts +++ b/cli/src/modules/common/peerTools.ts @@ -39,3 +39,31 @@ export function isPeerToolsDisabled(env: NodeJS.ProcessEnv = process.env): boole } return hubPreference === true } + +/** + * Env overlay resolving the kill switch for one session's captured preference. + * + * Used in two places that cannot read the module memory: + * - wrapped-agent child environments (claude/opencode/ACP spawn env, Codex + * per-root `shell_environment_policy.set`): an agent shelling out + * `hapi ping-peer` starts a fresh process where `hubPreference` is lost, so + * the resolved preference must travel as an explicit env value; + * - call-time prompt resolution inside one runner process hosting sessions + * with different captured preferences (a flipped global must not rewrite a + * live session's prompt), so both branches pin an explicit value. + * + * A non-empty machine-level `HAPI_DISABLE_PEER_TOOLS` stays authoritative + * (deploy-time override inherits naturally into children). The operator + * terminal never passes through a wrapped-agent spawn, so its exemption is + * untouched. + */ +export function peerToolsEnvOverlay(disabled: boolean, env: NodeJS.ProcessEnv = process.env): Record { + const raw = env.HAPI_DISABLE_PEER_TOOLS + if (raw !== undefined && raw !== '') return {} + return { HAPI_DISABLE_PEER_TOOLS: disabled ? '1' : '0' } +} + +/** Env for prompt/child resolution after applying the session overlay. */ +export function withPeerToolsPreference(disabled: boolean, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { ...env, ...peerToolsEnvOverlay(disabled, env) } +} diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts index 5ca705bf19..4cb9228ea4 100644 --- a/cli/src/modules/pingPeer/pingPeer.ts +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -41,9 +41,15 @@ export class PingPeerError extends Error { * HAPI_DISABLE_PEER_TOOLS): the MCP server and CLI surface refuse earlier, * but any other caller (stale --tools bridge, shell `hapi ping-peer` from a * wrapped agent) lands here and fails loudly. + * + * MCP handlers pass their session's create-time snapshot so a later session + * bootstrapping with a different hub preference cannot flip an + * already-registered tool (new-session-only semantics). Without the snapshot + * (CLI entry points) the module-wide resolution applies — env first, then the + * latest bootstrap — which keeps the operator terminal env-only. */ -function assertPeerToolsEnabled(): void { - if (isPeerToolsDisabled()) { +function assertPeerToolsEnabled(sessionPreference?: boolean): void { + if (sessionPreference ?? isPeerToolsDisabled()) { throw new PingPeerError( 'disabled', 'peer session tools are disabled (disabled by your hub administrator, or HAPI_DISABLE_PEER_TOOLS is set)' @@ -69,6 +75,8 @@ export type PingPeerSessionSummary = { export type PingPeerOptions = { sessionIdPrefix: string message: string + /** Create-time session snapshot; see assertPeerToolsEnabled. */ + peerToolsDisabled?: boolean waitActiveSecs?: number apiUrl?: string accessToken?: string @@ -86,6 +94,8 @@ export type PingPeerResult = { export type ListPeerSessionsOptions = { apiUrl?: string + /** Create-time session snapshot; see assertPeerToolsEnabled. */ + peerToolsDisabled?: boolean accessToken?: string http?: AxiosInstance limit?: number @@ -387,7 +397,7 @@ async function sendMessage( export async function listPeerSessions( options: ListPeerSessionsOptions = {} ): Promise { - assertPeerToolsEnabled() + assertPeerToolsEnabled(options.peerToolsDisabled) const apiUrl = resolveApiUrl(options.apiUrl) const accessToken = resolveAccessToken(options.accessToken) const http = options.http ?? axios @@ -474,7 +484,7 @@ export function formatPeerSessionsList( } export async function pingPeer(options: PingPeerOptions): Promise { - assertPeerToolsEnabled() + assertPeerToolsEnabled(options.peerToolsDisabled) const prefix = normalizeSessionIdPrefix(options.sessionIdPrefix ?? '') const message = options.message ?? '' if (!prefix) { @@ -566,6 +576,8 @@ export function exitCodeForPingPeerError(error: PingPeerError): number { export type InspectPeerOptions = { sessionIdPrefix: string + /** Create-time session snapshot; see assertPeerToolsEnabled. */ + peerToolsDisabled?: boolean /** Recent message page size (default 30, clamped 1..100). */ messageLimit?: number apiUrl?: string @@ -682,7 +694,7 @@ async function fetchSessionMessages( * Read-only: never resumes inactive sessions (unlike `pingPeer`). */ export async function inspectPeer(options: InspectPeerOptions): Promise { - assertPeerToolsEnabled() + assertPeerToolsEnabled(options.peerToolsDisabled) const prefix = normalizeSessionIdPrefix(options.sessionIdPrefix ?? '') if (!prefix) { throw new PingPeerError('bad_args', 'session id prefix is required') diff --git a/cli/src/opencode/opencodeLocalLauncher.ts b/cli/src/opencode/opencodeLocalLauncher.ts index 3699fb859c..2c4329c987 100644 --- a/cli/src/opencode/opencodeLocalLauncher.ts +++ b/cli/src/opencode/opencodeLocalLauncher.ts @@ -5,6 +5,7 @@ import { ensureOpencodeHookPlugin } from './utils/hookPlugin'; import { buildOpencodeEnv } from './utils/config'; import { ensureOpencodeConfig } from './utils/opencodeConfig'; import { getTitleInstruction } from './utils/systemPrompt'; +import { isPeerToolsDisabled, peerToolsEnvOverlay, withPeerToolsPreference } from '@/modules/common/peerTools'; import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; import type { OpencodeHookEvent } from './types'; import type { OpencodeHookServer } from './utils/startOpencodeHookServer'; @@ -194,7 +195,7 @@ export async function opencodeLocalLauncher( logger.debug(`[opencode-local]: Started hapi MCP server at ${happyServer.url}`); // Generate opencode.json config with MCP server and instructions - const { configPath } = ensureOpencodeConfig(opencodeConfigDir, bridge.mcpServers.hapi, getTitleInstruction()); + const { configPath } = ensureOpencodeConfig(opencodeConfigDir, bridge.mcpServers.hapi, getTitleInstruction(withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled()))); opencodeConfigPath = configPath; } catch (error) { logger.debug('[opencode-local]: Failed to start hapi MCP server (change_title will be unavailable)', error); @@ -209,6 +210,9 @@ export async function opencodeLocalLauncher( startingMode: session.startingMode, launch: async (abortSignal) => { const env = buildOpencodeEnv(); + // Propagate the resolved peer-tools switch onto the local agent + // process (see createOpencodeBackend for rationale). + Object.assign(env, peerToolsEnvOverlay(session.client.peerToolsDisabled ?? isPeerToolsDisabled())); env.HAPI_OPENCODE_HOOK_URL = hookUrl; env.HAPI_OPENCODE_HOOK_TOKEN = opts.hookServer.token; if (!env.OPENCODE_CONFIG_DIR) { diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 97c5c46d15..77937cd4b8 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -23,6 +23,7 @@ import { } from './utils/opencodeEventStream'; import { OpencodePermissionHandler } from './utils/permissionHandler'; import { getOpencodeNativeToolInstruction, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; import { resolveThoughtLevelEffort } from './thoughtLevelEffort'; type OpencodeRemoteLauncherOptions = { @@ -179,7 +180,8 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { const backend = createOpencodeBackend({ cwd: session.path, port, - hostname + hostname, + peerToolsDisabled: session.client.peerToolsDisabled }); this.backend = backend; registerAcpSessionTitleSync(backend, session.client); @@ -650,7 +652,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { messageText = `${PLAN_MODE_INSTRUCTION}\n\n${messageText}`; } if (!this.instructionsSent) { - messageText = `${getOpencodeNativeToolInstruction()}\n\n${messageText}`; + messageText = `${getOpencodeNativeToolInstruction(withPeerToolsPreference(session.client.peerToolsDisabled ?? isPeerToolsDisabled()))}\n\n${messageText}`; this.instructionsSent = true; } diff --git a/cli/src/opencode/utils/opencodeBackend.ts b/cli/src/opencode/utils/opencodeBackend.ts index 9e8d2abb5a..ceb3c5e5b3 100644 --- a/cli/src/opencode/utils/opencodeBackend.ts +++ b/cli/src/opencode/utils/opencodeBackend.ts @@ -1,6 +1,7 @@ import { createServer } from 'node:net'; import { AcpSdkBackend } from '@/agent/backends/acp'; import { buildOpencodeEnv } from './config'; +import { isPeerToolsDisabled, peerToolsEnvOverlay } from '@/modules/common/peerTools'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; @@ -51,8 +52,15 @@ export function createOpencodeBackend(opts: { cwd?: string; port?: number; hostname?: string; + /** Peer-tools switch captured from this session's bootstrap (env > hub). */ + peerToolsDisabled?: boolean; }): AcpSdkBackend { const env = buildOpencodeEnv(); + // Propagate the resolved peer-tools switch onto the wrapped agent so its + // shells (`hapi ping-peer` / `inspect-peer`) resolve the captured value + // instead of the wrapper's module memory. Machine-level env overrides stay + // authoritative inside peerToolsEnvOverlay. + Object.assign(env, peerToolsEnvOverlay(opts.peerToolsDisabled ?? isPeerToolsDisabled())); const args = ['acp', '--cwd', opts.cwd ?? getInvokedCwd()]; if (opts.port !== undefined) { args.push('--port', String(opts.port)); From 9dadd36e26953a82642fc7dde49fe31cc4088339 Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:52:06 +0800 Subject: [PATCH 10/14] fix(cli): satisfy excess-property check in session snapshot test Co-Authored-By: Claude Code via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- cli/src/api/apiSession.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index e5da8e09ff..9b1188f78e 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Session } from './types' +import type { SessionBootstrapInfo } from './api' const socketHarness = vi.hoisted(() => ({ sockets: [] as Array<{ @@ -173,7 +174,7 @@ function triggerIncomingUserMessage( describe('ApiSessionClient peer-tools snapshot (#2056)', () => { it('captures the create-time preference from the bootstrap row', () => { socketHarness.sockets.length = 0 - const client = new ApiSessionClient('token', { ...createSession(), peerToolsDisabled: true }) + const client = new ApiSessionClient('token', { ...createSession(), peerToolsDisabled: true } as SessionBootstrapInfo) expect(client.peerToolsDisabled).toBe(true) client.close() }) @@ -195,7 +196,7 @@ describe('ApiSessionClient peer-tools snapshot (#2056)', () => { client.close() socketHarness.sockets.length = 0 - const materializeDisabled = vi.fn(async () => ({ ...createSession({ namespace: 'default' }), peerToolsDisabled: true })) + const materializeDisabled = vi.fn(async () => ({ ...createSession({ namespace: 'default' }), peerToolsDisabled: true } as SessionBootstrapInfo)) const adopted = new ApiSessionClient('token', createSession(), { materialize: materializeDisabled }) adopted.sendUserMessage('hello') expect(await adopted.materialize()).toBe(true) From 912c873b9ad8d09e7f54217bdcb101978598155a Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:11:33 +0800 Subject: [PATCH 11/14] fix(cli): env-resolve peer-tools snapshots and pin child-process env Review round 2 follow-ups for the session-scoped peer-tools preference: - Capture the hub preference through the machine-level deploy override at capture time (new resolveSessionPeerToolsDisabled). Storing the raw hub boolean let a session bootstrapped while HAPI_DISABLE_PEER_TOOLS stands skip env resolution entirely; the snapshot now lands already resolved so every consumer (client.peerToolsDisabled ?? isPeerToolsDisabled()) reads a stable truth in both directions. - Return disablePeerTools from getSession so cold resumes (bootstrapExistingSession) capture their own snapshot instead of racing the process-wide module value on a later cross-flip. - Pin the session preference into the child env for local Cursor, local Grok, and Pi spawns (previously bare process.env), so agents shelling hapi ping-peer resolve the session state in the fresh process. Co-Authored-By: Claude Code via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- cli/src/api/api.ts | 17 +++-- cli/src/api/apiSession.test.ts | 48 +++++++++++++- cli/src/api/apiSession.ts | 23 ++++--- cli/src/cursor/cursorLocal.test.ts | 66 +++++++++++++++++++ cli/src/cursor/cursorLocal.ts | 7 +- cli/src/cursor/cursorLocalLauncher.ts | 1 + cli/src/grok/grokLocal.test.ts | 82 +++++++++++++++++++++++- cli/src/grok/grokLocal.ts | 7 +- cli/src/grok/grokLocalLauncher.ts | 3 +- cli/src/modules/common/peerTools.test.ts | 36 +++++++++++ cli/src/modules/common/peerTools.ts | 12 ++++ cli/src/pi/runPi.test.ts | 27 +++++++- cli/src/pi/runPi.ts | 5 +- 13 files changed, 313 insertions(+), 21 deletions(-) create mode 100644 cli/src/cursor/cursorLocal.test.ts diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index be7e0baca5..25f9e9c0d1 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -25,9 +25,10 @@ import { buildHubRequestHeaders } from './hubExtraHeaders' /** * Session row plus the hub-level peer-tools preference captured from this - * session's own create response. The create-time snapshot is authoritative - * for everything derived from this session (tool registration, prompts, - * wrapped-agent child env); absent when an older hub does not send it. + * session's own create/fetch response. The bootstrap-time snapshot is + * authoritative for everything derived from this session (tool registration, + * prompts, wrapped-agent child env); absent when an older hub does not send + * it. */ export type SessionBootstrapInfo = Session & { peerToolsDisabled?: boolean } @@ -148,7 +149,7 @@ export class ApiClient { } } - async getSession(sessionId: string): Promise { + async getSession(sessionId: string): Promise { const response = await axios.get( `${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}`, { @@ -201,7 +202,13 @@ export class ApiClient { effort: raw.effort, serviceTier: raw.serviceTier, permissionMode: raw.permissionMode, - collaborationMode: raw.collaborationMode + collaborationMode: raw.collaborationMode, + // Bootstrap-time peer-tools snapshot rides the row so existing- + // session bootstraps (cold resume) capture their own preference + // instead of racing the process-wide module value. + ...(typeof parsed.data.disablePeerTools === 'boolean' + ? { peerToolsDisabled: parsed.data.disablePeerTools } + : {}) } } diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index 9b1188f78e..4437d77f6f 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { Session } from './types' import type { SessionBootstrapInfo } from './api' +import { resetPeerToolsForTests } from '@/modules/common/peerTools' const socketHarness = vi.hoisted(() => ({ sockets: [] as Array<{ @@ -172,6 +173,17 @@ function triggerIncomingUserMessage( } describe('ApiSessionClient peer-tools snapshot (#2056)', () => { + const originalEnv = process.env.HAPI_DISABLE_PEER_TOOLS + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = originalEnv + } + resetPeerToolsForTests() + }) + it('captures the create-time preference from the bootstrap row', () => { socketHarness.sockets.length = 0 const client = new ApiSessionClient('token', { ...createSession(), peerToolsDisabled: true } as SessionBootstrapInfo) @@ -203,6 +215,40 @@ describe('ApiSessionClient peer-tools snapshot (#2056)', () => { expect(adopted.peerToolsDisabled).toBe(true) adopted.close() }) + + it('resolves the snapshot through the machine env at capture time (both directions)', () => { + socketHarness.sockets.length = 0 + delete process.env.HAPI_DISABLE_PEER_TOOLS + const faithful = new ApiSessionClient('token', { ...createSession(), peerToolsDisabled: true } as SessionBootstrapInfo) + expect(faithful.peerToolsDisabled).toBe(true) + faithful.close() + + socketHarness.sockets.length = 0 + // A non-empty deploy override must beat the hub value in BOTH + // directions at capture, so the stored snapshot never flips later + // with the process-wide module value (#2056 round 2). + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + const machineWinsDisable = new ApiSessionClient('token', { ...createSession(), peerToolsDisabled: false } as SessionBootstrapInfo) + expect(machineWinsDisable.peerToolsDisabled).toBe(true) + machineWinsDisable.close() + + socketHarness.sockets.length = 0 + process.env.HAPI_DISABLE_PEER_TOOLS = '0' + const machineWinsEnable = new ApiSessionClient('token', { ...createSession(), peerToolsDisabled: true } as SessionBootstrapInfo) + expect(machineWinsEnable.peerToolsDisabled).toBe(false) + machineWinsEnable.close() + }) + + it('resolves the adopted snapshot through the machine env too (lazy materialization)', async () => { + socketHarness.sockets.length = 0 + process.env.HAPI_DISABLE_PEER_TOOLS = '0' + const materializeDisabled = vi.fn(async () => ({ ...createSession({ namespace: 'default' }), peerToolsDisabled: true } as SessionBootstrapInfo)) + const adopted = new ApiSessionClient('token', createSession(), { materialize: materializeDisabled }) + adopted.sendUserMessage('hello') + expect(await adopted.materialize()).toBe(true) + expect(adopted.peerToolsDisabled).toBe(false) + adopted.close() + }) }) describe('ApiSessionClient lazy materialization', () => { diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index a49b220574..9de4af1e13 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -8,6 +8,7 @@ import { logger } from '@/ui/logger' import { backoff } from '@/utils/time' import { apiValidationError } from '@/utils/errorUtils' import type { SessionBootstrapInfo } from './api' +import { resolveSessionPeerToolsDisabled } from '@/modules/common/peerTools' import { AsyncLock } from '@/utils/lock' import type { RawJSONLines } from '@/claude/types' import { configuration } from '@/configuration' @@ -245,12 +246,14 @@ export class ApiSessionClient extends EventEmitter { */ hubArchived = false /** - * Peer-tools kill switch captured from THIS session's create response - * (undefined when an older hub does not send it, or until a lazy pending - * session materializes). Authoritative for tool registration, prompts and - * wrapped-agent child env derived from this session; the module-wide - * preference only serves as fallback (#2056 review: keep the resolved - * preference scoped per session/root). + * Peer-tools kill switch captured from THIS session's create/fetch + * response and resolved through the machine-level deploy override at + * capture time (a non-empty HAPI_DISABLE_PEER_TOOLS beats the hub value in + * both directions; undefined when an older hub does not send it, or until + * a lazy pending session materializes). Authoritative for tool + * registration, prompts and wrapped-agent child env derived from this + * session; the module-wide preference only serves as fallback (#2056 + * review: keep the resolved preference scoped per session/root). */ peerToolsDisabled?: boolean private readonly token: string @@ -309,7 +312,8 @@ export class ApiSessionClient extends EventEmitter { this.metadataVersion = session.metadataVersion this.agentState = session.agentState this.agentStateVersion = session.agentStateVersion - this.peerToolsDisabled = (session as SessionBootstrapInfo).peerToolsDisabled + const bootstrapSnapshot = (session as SessionBootstrapInfo).peerToolsDisabled + this.peerToolsDisabled = bootstrapSnapshot === undefined ? undefined : resolveSessionPeerToolsDisabled(bootstrapSnapshot) this.materializer = options.materialize this.onMaterialized = options.onMaterialized this.state = this.materializer ? 'pending' : 'active' @@ -617,7 +621,10 @@ export class ApiSessionClient extends EventEmitter { this.metadataVersion = materialized.metadataVersion this.agentState = materialized.agentState this.agentStateVersion = materialized.agentStateVersion - this.peerToolsDisabled = (materialized as SessionBootstrapInfo).peerToolsDisabled ?? this.peerToolsDisabled + const materializedSnapshot = (materialized as SessionBootstrapInfo).peerToolsDisabled + this.peerToolsDisabled = materializedSnapshot === undefined + ? this.peerToolsDisabled + : resolveSessionPeerToolsDisabled(materializedSnapshot) this.state = 'active' if (shouldSyncMetadata && latestMetadata) { diff --git a/cli/src/cursor/cursorLocal.test.ts b/cli/src/cursor/cursorLocal.test.ts new file mode 100644 index 0000000000..864546b98f --- /dev/null +++ b/cli/src/cursor/cursorLocal.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cursorLocal } from './cursorLocal'; +import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; +import { resetPeerToolsForTests } from '@/modules/common/peerTools'; + +vi.mock('@/utils/spawnWithTerminalGuard', () => ({ + spawnWithTerminalGuard: vi.fn(async () => {}) +})); + +vi.mock('@/agent/agentLaunchCommand', () => ({ + getAgentLaunchCommand: vi.fn(() => 'cursor-agent') +})); + +describe('cursorLocal peer-tools child env (#2056)', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS; + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original; + } + resetPeerToolsForTests(); + vi.mocked(spawnWithTerminalGuard).mockClear(); + }); + + async function spawnedEnv(): Promise { + const call = vi.mocked(spawnWithTerminalGuard).mock.calls.at(-1); + expect(call).toBeDefined(); + return call![0].env ?? {}; + } + + async function launchWith(peerToolsDisabled?: boolean): Promise { + await cursorLocal({ + abort: new AbortController().signal, + chatId: null, + path: '/work', + ...(peerToolsDisabled === undefined ? {} : { peerToolsDisabled }) + }); + } + + it('pins the disabled session snapshot into the child env', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + await launchWith(true); + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('1'); + }); + + it('pins an explicit enabled snapshot so later global flips cannot rewrite the child', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + await launchWith(false); + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('0'); + }); + + it('keeps a machine-level deploy override authoritative over the snapshot', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1'; + await launchWith(false); + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('1'); + }); + + it('falls back to the module state when no snapshot was captured', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + await launchWith(); + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('0'); + }); +}); diff --git a/cli/src/cursor/cursorLocal.ts b/cli/src/cursor/cursorLocal.ts index 8ef116de74..7fa2d49e77 100644 --- a/cli/src/cursor/cursorLocal.ts +++ b/cli/src/cursor/cursorLocal.ts @@ -1,6 +1,7 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; /** * Filter out 'resume' subcommand which is managed internally by hapi. @@ -32,6 +33,8 @@ export async function cursorLocal(opts: { addDirs?: readonly string[]; onChatFound?: (chatId: string) => void; cursorArgs?: string[]; + /** Session peer-tools snapshot; resolved preference pins the child env. */ + peerToolsDisabled?: boolean; }): Promise { const args: string[] = []; @@ -86,7 +89,9 @@ export async function cursorLocal(opts: { command: getAgentLaunchCommand('cursor'), args, cwd: opts.path, - env: process.env, + // Pin the session's peer-tools preference so a Cursor agent shelling + // `hapi ping-peer` resolves it in the fresh child process (#2056). + env: withPeerToolsPreference(opts.peerToolsDisabled ?? isPeerToolsDisabled()), signal: opts.abort, logLabel: 'CursorLocal', spawnName: 'agent', diff --git a/cli/src/cursor/cursorLocalLauncher.ts b/cli/src/cursor/cursorLocalLauncher.ts index 4e971b1042..46e55c5bf9 100644 --- a/cli/src/cursor/cursorLocalLauncher.ts +++ b/cli/src/cursor/cursorLocalLauncher.ts @@ -53,6 +53,7 @@ export async function cursorLocalLauncher(session: CursorSession): Promise<'swit autoReview, worktree: session.cursorWorktree, addDirs: session.cursorAddDirs, + peerToolsDisabled: session.client.peerToolsDisabled, onChatFound: (chatId) => session.onSessionFound(chatId) }); }, diff --git a/cli/src/grok/grokLocal.test.ts b/cli/src/grok/grokLocal.test.ts index 73706f3c67..6d333e9d64 100644 --- a/cli/src/grok/grokLocal.test.ts +++ b/cli/src/grok/grokLocal.test.ts @@ -1,5 +1,16 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { buildGrokLocalArgs } from './grokLocal' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildGrokLocalArgs, grokLocal } from './grokLocal' +import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard' +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand' +import { resetPeerToolsForTests } from '@/modules/common/peerTools' + +vi.mock('@/utils/spawnWithTerminalGuard', () => ({ + spawnWithTerminalGuard: vi.fn(async () => {}) +})) + +vi.mock('@/agent/agentLaunchCommand', () => ({ + getAgentLaunchCommand: vi.fn(() => 'grok') +})) const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') @@ -57,3 +68,70 @@ describe('buildGrokLocalArgs', () => { })).toThrow('Invalid effort') }) }) + +describe('grokLocal peer-tools child env (#2056)', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original + } + resetPeerToolsForTests() + vi.mocked(spawnWithTerminalGuard).mockClear() + }) + + async function spawnedEnv(): Promise { + const call = vi.mocked(spawnWithTerminalGuard).mock.calls.at(-1) + expect(call).toBeDefined() + return call![0].env ?? {} + } + + it('pins the disabled session snapshot into the child env', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + await grokLocal({ + sessionId: 'grok-session-1', + resume: false, + path: '/work', + abort: new AbortController().signal, + peerToolsDisabled: true + }) + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('1') + }) + + it('pins an explicit enabled snapshot so later global flips cannot rewrite the child', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + await grokLocal({ + sessionId: 'grok-session-1', + resume: false, + path: '/work', + abort: new AbortController().signal, + peerToolsDisabled: false + }) + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('0') + }) + + it('keeps a machine-level deploy override authoritative over the snapshot', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + await grokLocal({ + sessionId: 'grok-session-1', + resume: false, + path: '/work', + abort: new AbortController().signal, + peerToolsDisabled: false + }) + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('1') + }) + + it('falls back to the module state when no snapshot was captured', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + await grokLocal({ + sessionId: 'grok-session-1', + resume: false, + path: '/work', + abort: new AbortController().signal + }) + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('0') + }) +}) diff --git a/cli/src/grok/grokLocal.ts b/cli/src/grok/grokLocal.ts index 4310a42e7d..cc652db978 100644 --- a/cli/src/grok/grokLocal.ts +++ b/cli/src/grok/grokLocal.ts @@ -3,6 +3,7 @@ import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard' import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand' import type { PermissionMode } from './types' import { assertSafeWindowsShellArg } from './utils/windowsShellArgs' +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools' type GrokLocalOptions = { sessionId: string @@ -10,6 +11,8 @@ type GrokLocalOptions = { model?: string effort?: string permissionMode?: PermissionMode + /** Session peer-tools snapshot; resolved preference pins the child env. */ + peerToolsDisabled?: boolean } export function buildGrokLocalArgs(opts: GrokLocalOptions): string[] { @@ -48,7 +51,9 @@ export async function grokLocal(opts: GrokLocalOptions & { command: getAgentLaunchCommand('grok'), args, cwd: opts.path, - env: process.env, + // Pin the session's peer-tools preference so a Grok agent shelling + // `hapi ping-peer` resolves it in the fresh child process (#2056). + env: withPeerToolsPreference(opts.peerToolsDisabled ?? isPeerToolsDisabled()), signal: opts.abort, shell: process.platform === 'win32', logLabel: 'GrokLocal', diff --git a/cli/src/grok/grokLocalLauncher.ts b/cli/src/grok/grokLocalLauncher.ts index c2b27fab23..f13dde02ec 100644 --- a/cli/src/grok/grokLocalLauncher.ts +++ b/cli/src/grok/grokLocalLauncher.ts @@ -27,7 +27,8 @@ export async function grokLocalLauncher( abort: abortSignal, model: opts.model, effort: opts.effort, - permissionMode: session.getPermissionMode() as PermissionMode | undefined + permissionMode: session.getPermissionMode() as PermissionMode | undefined, + peerToolsDisabled: session.client.peerToolsDisabled }) }, sendFailureMessage: (message) => { diff --git a/cli/src/modules/common/peerTools.test.ts b/cli/src/modules/common/peerTools.test.ts index 7d9c7ff9a5..23be829a46 100644 --- a/cli/src/modules/common/peerTools.test.ts +++ b/cli/src/modules/common/peerTools.test.ts @@ -4,6 +4,7 @@ import { isPeerToolsDisabled, peerToolsEnvOverlay, resetPeerToolsForTests, + resolveSessionPeerToolsDisabled, withPeerToolsPreference } from './peerTools' @@ -115,3 +116,38 @@ describe('withPeerToolsPreference', () => { expect(source).toEqual({ PATH: '/usr/bin' }) }) }) + +describe('resolveSessionPeerToolsDisabled', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original + } + resetPeerToolsForTests() + }) + + it('honors the hub snapshot in both directions when env is unset', () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS + expect(resolveSessionPeerToolsDisabled(true)).toBe(true) + expect(resolveSessionPeerToolsDisabled(false)).toBe(false) + }) + + it('lets a machine-level env beat the hub snapshot at capture time (both directions)', () => { + // The snapshot must land already env-resolved, otherwise a session + // captured while the deploy override stands would later flip with + // the process-wide module value (#2056 round 2). + process.env.HAPI_DISABLE_PEER_TOOLS = '1' + expect(resolveSessionPeerToolsDisabled(false)).toBe(true) + + process.env.HAPI_DISABLE_PEER_TOOLS = '0' + expect(resolveSessionPeerToolsDisabled(true)).toBe(false) + }) + + it('treats an unrecognized env value as disabled (fail-safe kill switch)', () => { + process.env.HAPI_DISABLE_PEER_TOOLS = 'banana' + expect(resolveSessionPeerToolsDisabled(false)).toBe(true) + }) +}) diff --git a/cli/src/modules/common/peerTools.ts b/cli/src/modules/common/peerTools.ts index 58bead633b..fe05d6425f 100644 --- a/cli/src/modules/common/peerTools.ts +++ b/cli/src/modules/common/peerTools.ts @@ -67,3 +67,15 @@ export function peerToolsEnvOverlay(disabled: boolean, env: NodeJS.ProcessEnv = export function withPeerToolsPreference(disabled: boolean, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...env, ...peerToolsEnvOverlay(disabled, env) } } + +/** + * Resolve a session's create-time hub preference into the effective state, + * honoring the machine-level deploy override: a non-empty + * `HAPI_DISABLE_PEER_TOOLS` beats the hub value in both directions, otherwise + * the hub value stands. ApiSessionClient captures snapshots through this so + * every consumer (`client.peerToolsDisabled ?? isPeerToolsDisabled()`) reads + * an already-env-resolved truth instead of the raw hub boolean. + */ +export function resolveSessionPeerToolsDisabled(hubDisabled: boolean): boolean { + return isPeerToolsDisabled(withPeerToolsPreference(hubDisabled)) +} diff --git a/cli/src/pi/runPi.test.ts b/cli/src/pi/runPi.test.ts index 6379553b6a..19c85f2581 100644 --- a/cli/src/pi/runPi.test.ts +++ b/cli/src/pi/runPi.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -type TransportOptions = { command: string; args: string[]; cwd: string }; +type TransportOptions = { command: string; args: string[]; cwd: string; env?: NodeJS.ProcessEnv }; type LifecycleOptions = { stopKeepAlive: () => void }; const harness = vi.hoisted(() => ({ @@ -200,6 +200,31 @@ describe('runPi startup', () => { ]); }); + it('pins the session peer-tools preference into the Pi transport env (#2056)', async () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS; + try { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + + await runPi({ workingDirectory: '/work' }); + expect(harness.transportOptions?.env?.HAPI_DISABLE_PEER_TOOLS).toBe('0'); + + const session = harness.session as { peerToolsDisabled?: boolean }; + session.peerToolsDisabled = true; + try { + await runPi({ workingDirectory: '/work' }); + expect(harness.transportOptions?.env?.HAPI_DISABLE_PEER_TOOLS).toBe('1'); + } finally { + delete session.peerToolsDisabled; + } + } finally { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original; + } + } + }); + it('resumes with --session and keeps the session selected by Pi', async () => { await runPi({ workingDirectory: '/work', diff --git a/cli/src/pi/runPi.ts b/cli/src/pi/runPi.ts index 55e1e70d1c..5f1c201555 100644 --- a/cli/src/pi/runPi.ts +++ b/cli/src/pi/runPi.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { logger } from '@/ui/logger'; import { convertAgentMessage } from '@/agent/messageConverter'; import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; import { registerLocalHandoffHandler } from '@/agent/localHandoff'; import { createRunnerLifecycle, createModeChangeHandler, setControlledByUser } from '@/agent/runnerLifecycle'; @@ -252,7 +253,9 @@ export async function runPi(opts: { command: getAgentLaunchCommand('pi'), args: transportArgs, cwd: workingDirectory, - env: process.env, + // Pin the session's peer-tools preference so a Pi agent shelling + // `hapi ping-peer` resolves it in the fresh child process (#2056). + env: withPeerToolsPreference(apiSession.peerToolsDisabled ?? isPeerToolsDisabled()), }); const conversationHistory = new PiConversationHistory( piSession, From a07e0e050a5587fd86ca7a1592713dd4d1702a03 Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:48:55 +0800 Subject: [PATCH 12/14] fix(cli): pin peer-tools env for Antigravity and legacy Cursor children Review round 3 follow-up: two more wrapped-agent spawn paths still passed a bare process.env, so a fresh `hapi ping-peer` from those agents resolved neither the session snapshot nor the module state: - agyHeadlessDriver: buildAgySpawnEnv now applies the session-resolved preference on top of the existing GEMINI/SSH hardening (machine-level override stays authoritative, process.env not mutated). - cursorLegacyRemoteLauncher: the pre-ACP stream-json resume spawn pins the session snapshot the same way. Co-Authored-By: Claude Code via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../agy/headless/agyHeadlessDriver.test.ts | 45 ++++++++++++++- cli/src/agy/headless/agyHeadlessDriver.ts | 11 +++- .../cursor/cursorLegacyRemoteLauncher.test.ts | 55 +++++++++++++++++++ cli/src/cursor/cursorLegacyRemoteLauncher.ts | 6 +- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/cli/src/agy/headless/agyHeadlessDriver.test.ts b/cli/src/agy/headless/agyHeadlessDriver.test.ts index f66f795bda..557a7c35da 100644 --- a/cli/src/agy/headless/agyHeadlessDriver.test.ts +++ b/cli/src/agy/headless/agyHeadlessDriver.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { EventEmitter } from 'node:events'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { logger } from '@/ui/logger'; -import { buildAgyHeadlessArgs, AgyHeadlessDriver } from './agyHeadlessDriver'; +import { buildAgyHeadlessArgs, buildAgySpawnEnv, AgyHeadlessDriver } from './agyHeadlessDriver'; import { AgySession } from '../session'; import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { resetPeerToolsForTests } from '@/modules/common/peerTools'; import type { AgyMode, PermissionMode } from '../types'; // A fake agy binary: emits a fixed NDJSON stream for one turn, then exits 0. @@ -1409,3 +1410,43 @@ describe('AgyHeadlessDriver', () => { expect(String(errorEvents[0][0].message)).toContain('exited with code 2'); }, 15000); }); + +describe('buildAgySpawnEnv peer-tools pinning (#2056)', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS; + const originalSshClient = process.env.SSH_CLIENT; + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original; + } + if (originalSshClient === undefined) { + delete process.env.SSH_CLIENT; + } else { + process.env.SSH_CLIENT = originalSshClient; + } + resetPeerToolsForTests(); + }); + + it('pins the session snapshot and keeps the agy hardening vars', () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + process.env.SSH_CLIENT = '1.2.3.4 55555 22'; + const env = buildAgySpawnEnv(true); + expect(env.HAPI_DISABLE_PEER_TOOLS).toBe('1'); + expect(env.GEMINI_FORCE_FILE_STORAGE).toBe('true'); + expect(env.SSH_CLIENT).toBeUndefined(); + }); + + it('pins an explicit enabled snapshot', () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + expect(buildAgySpawnEnv(false).HAPI_DISABLE_PEER_TOOLS).toBe('0'); + }); + + it('keeps a machine-level deploy override authoritative and does not mutate process.env', () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1'; + const env = buildAgySpawnEnv(false); + expect(env.HAPI_DISABLE_PEER_TOOLS).toBe('1'); + expect(process.env.GEMINI_FORCE_FILE_STORAGE).toBeUndefined(); + }); +}); diff --git a/cli/src/agy/headless/agyHeadlessDriver.ts b/cli/src/agy/headless/agyHeadlessDriver.ts index dc0b04c56e..b1e89c69f1 100644 --- a/cli/src/agy/headless/agyHeadlessDriver.ts +++ b/cli/src/agy/headless/agyHeadlessDriver.ts @@ -17,6 +17,7 @@ import { resolveAgyTurnModels } from '../utils/agyConversationModel'; import { killProcessByChildProcess } from '@/utils/process'; import { AGY_MODEL_LABELS } from '@hapi/protocol'; import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; const AGY_PRINT_TIMEOUT = '30m'; @@ -76,13 +77,17 @@ export function buildAgyHeadlessArgs(opts: { * the forwarded SSH agent. Only the session markers are removed (so agy does not * classify the run as an SSH session and switch auth paths); SSH_AUTH_SOCK and * SSH_AGENT_PID are kept for tool subprocesses. + * + * The session's peer-tools preference is pinned on top (#2056) so an agy agent + * shelling `hapi ping-peer` resolves it in the fresh child process; a non-empty + * machine-level HAPI_DISABLE_PEER_TOOLS stays authoritative (no overlay). */ -function buildAgySpawnEnv(): NodeJS.ProcessEnv { +export function buildAgySpawnEnv(peerToolsDisabled?: boolean): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, GEMINI_FORCE_FILE_STORAGE: 'true' } for (const key of ['SSH_CLIENT', 'SSH_CONNECTION', 'SSH_TTY']) { delete env[key] } - return env + return withPeerToolsPreference(peerToolsDisabled ?? isPeerToolsDisabled(), env) } export type AgyHeadlessDriverOptions = { @@ -121,7 +126,7 @@ export class AgyHeadlessDriver extends RemoteLauncherBase { const child = spawn(getAgentLaunchCommand('agy'), args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], - env: buildAgySpawnEnv(), + env: buildAgySpawnEnv(this.session.client.peerToolsDisabled), }) as ChildProcessWithoutNullStreams; return child; }); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts index 9b18c6c622..296ad0911d 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts @@ -126,6 +126,61 @@ describe('cursorLegacyRemoteLauncher', () => { expect(args).not.toContain('acp'); }); + it('pins the session peer-tools preference into the legacy agent env (#2056)', async () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS; + try { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + const child = makeChild(); + spawnMock.mockReturnValue(child); + + const queue = new MessageQueue2(() => 'm'); + queue.push('hello', { permissionMode: 'default' }); + queue.close(); + + const client = { ...makeClient(), peerToolsDisabled: true }; + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect((spawnMock.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv }).env.HAPI_DISABLE_PEER_TOOLS).toBe('1'); + } finally { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original; + } + } + }); + + it('keeps a machine-level deploy override authoritative over the snapshot (#2056)', async () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS; + try { + process.env.HAPI_DISABLE_PEER_TOOLS = '1'; + const child = makeChild(); + spawnMock.mockReturnValue(child); + + const queue = new MessageQueue2(() => 'm'); + queue.push('hello', { permissionMode: 'default' }); + queue.close(); + + const client = { ...makeClient(), peerToolsDisabled: false }; + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect((spawnMock.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv }).env.HAPI_DISABLE_PEER_TOOLS).toBe('1'); + } finally { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original; + } + } + }); + it('requeues the user message and surfaces an auth banner when agent exits with auth-required stderr', async () => { const queue = new MessageQueue2(() => 'm'); queue.push('do thing', { permissionMode: 'default' }); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index 89eca09f14..23691a5abd 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -14,6 +14,7 @@ import type { CursorSession } from './session'; import type { EnhancedMode } from './loop'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; // TODO(cursor-acp): remove legacy stream-json resume path after migration window. // New Cursor sessions use ACP only. This path exists because pre-ACP Cursor // session_id values are not loadable via ACP session/load. @@ -269,7 +270,10 @@ class CursorRemoteLauncher extends RemoteLauncherBase { return new Promise((resolve, reject) => { const child = spawn(getAgentLaunchCommand('cursor'), args, { cwd, - env: process.env, + // Pin the session's peer-tools preference so a legacy Cursor + // agent shelling `hapi ping-peer` resolves it in the fresh + // child process (#2056). + env: withPeerToolsPreference(this.session.client.peerToolsDisabled ?? isPeerToolsDisabled()), stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: process.platform === 'win32' From 8680f73e44b5db49979651acd2b018a91e417deb Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:51:48 +0800 Subject: [PATCH 13/14] fix(cli): pin peer-tools env for local Copilot children Same wrapped-agent spawn category as the round-2/3 fixes, swept proactively: copilotLocal still passed a bare process.env, so a fresh `hapi ping-peer` from a local Copilot agent resolved neither the session snapshot nor the module state. Pins the session-resolved preference via withPeerToolsPreference (machine-level override stays authoritative); the remote path already pinned it through the backend env. Co-Authored-By: Claude Code via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- cli/src/copilot/copilotLocal.test.ts | 61 ++++++++++++++++++++++++- cli/src/copilot/copilotLocal.ts | 7 ++- cli/src/copilot/copilotLocalLauncher.ts | 3 +- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/cli/src/copilot/copilotLocal.test.ts b/cli/src/copilot/copilotLocal.test.ts index 01a6bfd921..6ba25c39b4 100644 --- a/cli/src/copilot/copilotLocal.test.ts +++ b/cli/src/copilot/copilotLocal.test.ts @@ -1,5 +1,16 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { buildCopilotLocalArgs } from './copilotLocal'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildCopilotLocalArgs, copilotLocal } from './copilotLocal'; +import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; +import { resetPeerToolsForTests } from '@/modules/common/peerTools'; + +vi.mock('@/utils/spawnWithTerminalGuard', () => ({ + spawnWithTerminalGuard: vi.fn(async () => {}) +})); + +vi.mock('@/agent/agentLaunchCommand', () => ({ + getAgentLaunchCommand: vi.fn(() => 'copilot') +})); const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); @@ -20,3 +31,49 @@ describe('buildCopilotLocalArgs', () => { expect(() => buildCopilotLocalArgs({ sessionId: 'session-1', model: 'gpt|whoami' })).toThrow('Invalid model'); }); }); + +describe('copilotLocal peer-tools child env (#2056)', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS; + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original; + } + resetPeerToolsForTests(); + vi.mocked(spawnWithTerminalGuard).mockClear(); + }); + + async function spawnedEnv(peerToolsDisabled?: boolean): Promise { + await copilotLocal({ + path: '/work', + sessionId: 'copilot-session-1', + abort: new AbortController().signal, + ...(peerToolsDisabled === undefined ? {} : { peerToolsDisabled }) + }); + const call = vi.mocked(spawnWithTerminalGuard).mock.calls.at(-1); + expect(call).toBeDefined(); + return call![0].env ?? {}; + } + + it('pins the disabled session snapshot into the child env', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + expect((await spawnedEnv(true)).HAPI_DISABLE_PEER_TOOLS).toBe('1'); + }); + + it('pins an explicit enabled snapshot', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + expect((await spawnedEnv(false)).HAPI_DISABLE_PEER_TOOLS).toBe('0'); + }); + + it('keeps a machine-level deploy override authoritative over the snapshot', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1'; + expect((await spawnedEnv(false)).HAPI_DISABLE_PEER_TOOLS).toBe('1'); + }); + + it('falls back to the module state when no snapshot was captured', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('0'); + }); +}); diff --git a/cli/src/copilot/copilotLocal.ts b/cli/src/copilot/copilotLocal.ts index 93fc94ee9d..1d8f533ac8 100644 --- a/cli/src/copilot/copilotLocal.ts +++ b/cli/src/copilot/copilotLocal.ts @@ -1,6 +1,7 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; import type { CopilotAgentMode } from '@hapi/protocol'; import { assertSafeWindowsShellArg } from '@/grok/utils/windowsShellArgs'; @@ -29,6 +30,8 @@ export async function copilotLocal(opts: { model?: string; yolo?: boolean; agentMode?: CopilotAgentMode; + /** Session peer-tools snapshot; resolved preference pins the child env. */ + peerToolsDisabled?: boolean; }): Promise { const args = buildCopilotLocalArgs(opts); @@ -38,7 +41,9 @@ export async function copilotLocal(opts: { command: getAgentLaunchCommand('copilot'), args, cwd: opts.path, - env: process.env, + // Pin the session's peer-tools preference so a Copilot agent shelling + // `hapi ping-peer` resolves it in the fresh child process (#2056). + env: withPeerToolsPreference(opts.peerToolsDisabled ?? isPeerToolsDisabled()), signal: opts.abort, shell: process.platform === 'win32', logLabel: 'CopilotLocal', diff --git a/cli/src/copilot/copilotLocalLauncher.ts b/cli/src/copilot/copilotLocalLauncher.ts index 6491eb9683..ad454a8f87 100644 --- a/cli/src/copilot/copilotLocalLauncher.ts +++ b/cli/src/copilot/copilotLocalLauncher.ts @@ -55,7 +55,8 @@ export async function copilotLocalLauncher( abort: abortSignal, model: opts.model, yolo: approval.yolo, - agentMode: session.getAgentMode() + agentMode: session.getAgentMode(), + peerToolsDisabled: session.client.peerToolsDisabled }); }, sendFailureMessage: (message) => { From e62ca723d1e6ea89ff104896dc71de759c19c024 Mon Sep 17 00:00:00 2001 From: miui <33443359+miuiadmin@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:01:05 +0800 Subject: [PATCH 14/14] fix(cli): pin peer-tools env for local Kimi launches Review round 4 follow-up: kimiLocal built its child env from a bare process.env copy and kimiLocalLauncher passed no preference, so a fresh `hapi ping-peer` from a local Kimi agent resolved neither the session snapshot nor the module state. Threads session.client.peerToolsDisabled through the launcher and applies withPeerToolsPreference (machine-level override stays authoritative). Co-Authored-By: Claude Code via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- cli/src/kimi/kimiLocal.test.ts | 59 ++++++++++++++++++++++++++ cli/src/kimi/kimiLocal.ts | 11 ++--- cli/src/kimi/kimiLocalLauncher.test.ts | 41 ++++++++++++++++++ cli/src/kimi/kimiLocalLauncher.ts | 3 +- 4 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 cli/src/kimi/kimiLocal.test.ts diff --git a/cli/src/kimi/kimiLocal.test.ts b/cli/src/kimi/kimiLocal.test.ts new file mode 100644 index 0000000000..f138c0d165 --- /dev/null +++ b/cli/src/kimi/kimiLocal.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { kimiLocal } from './kimiLocal'; +import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; +import { resetPeerToolsForTests } from '@/modules/common/peerTools'; + +vi.mock('@/utils/spawnWithTerminalGuard', () => ({ + spawnWithTerminalGuard: vi.fn(async () => {}) +})); + +vi.mock('@/agent/agentLaunchCommand', () => ({ + getAgentLaunchCommand: vi.fn(() => 'kimi') +})); + +describe('kimiLocal peer-tools child env (#2056)', () => { + const original = process.env.HAPI_DISABLE_PEER_TOOLS; + + afterEach(() => { + if (original === undefined) { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + } else { + process.env.HAPI_DISABLE_PEER_TOOLS = original; + } + resetPeerToolsForTests(); + vi.mocked(spawnWithTerminalGuard).mockClear(); + }); + + async function spawnedEnv(peerToolsDisabled?: boolean): Promise { + await kimiLocal({ + path: '/work', + sessionId: 'kimi-session-1', + abort: new AbortController().signal, + ...(peerToolsDisabled === undefined ? {} : { peerToolsDisabled }) + }); + const call = vi.mocked(spawnWithTerminalGuard).mock.calls.at(-1); + expect(call).toBeDefined(); + return call![0].env ?? {}; + } + + it('pins the disabled session snapshot into the child env', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + expect((await spawnedEnv(true)).HAPI_DISABLE_PEER_TOOLS).toBe('1'); + }); + + it('pins an explicit enabled snapshot', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + expect((await spawnedEnv(false)).HAPI_DISABLE_PEER_TOOLS).toBe('0'); + }); + + it('keeps a machine-level deploy override authoritative over the snapshot', async () => { + process.env.HAPI_DISABLE_PEER_TOOLS = '1'; + expect((await spawnedEnv(false)).HAPI_DISABLE_PEER_TOOLS).toBe('1'); + }); + + it('falls back to the module state when no snapshot was captured', async () => { + delete process.env.HAPI_DISABLE_PEER_TOOLS; + expect((await spawnedEnv()).HAPI_DISABLE_PEER_TOOLS).toBe('0'); + }); +}); diff --git a/cli/src/kimi/kimiLocal.ts b/cli/src/kimi/kimiLocal.ts index 44ad2db218..b89849580c 100644 --- a/cli/src/kimi/kimiLocal.ts +++ b/cli/src/kimi/kimiLocal.ts @@ -1,6 +1,7 @@ import { logger } from '@/ui/logger'; import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; +import { isPeerToolsDisabled, withPeerToolsPreference } from '@/modules/common/peerTools'; export async function kimiLocal(opts: { path: string; @@ -9,6 +10,8 @@ export async function kimiLocal(opts: { model?: string; yolo?: boolean; plan?: boolean; + /** Session peer-tools snapshot; resolved preference pins the child env. */ + peerToolsDisabled?: boolean; }): Promise { const args: string[] = []; @@ -25,17 +28,15 @@ export async function kimiLocal(opts: { args.push('--plan'); } - const env: NodeJS.ProcessEnv = { - ...process.env - }; - logger.debug(`[KimiLocal] Spawning kimi with args: ${JSON.stringify(args)}`); await spawnWithTerminalGuard({ command: getAgentLaunchCommand('kimi'), args, cwd: opts.path, - env, + // Pin the session's peer-tools preference so a Kimi agent shelling + // `hapi ping-peer` resolves it in the fresh child process (#2056). + env: withPeerToolsPreference(opts.peerToolsDisabled ?? isPeerToolsDisabled()), signal: opts.abort, shell: process.platform === 'win32', logLabel: 'KimiLocal', diff --git a/cli/src/kimi/kimiLocalLauncher.test.ts b/cli/src/kimi/kimiLocalLauncher.test.ts index 4f1b7e13ee..8f5e3406c2 100644 --- a/cli/src/kimi/kimiLocalLauncher.test.ts +++ b/cli/src/kimi/kimiLocalLauncher.test.ts @@ -2,6 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Metadata } from '@/api/types'; const harness = vi.hoisted(() => ({ + launcherOptions: null as null | { + launch: (abortSignal: AbortSignal) => Promise; + }, locatorOptions: null as null | { onLocated: (located: { sessionId: string; wirePath: string; statePath: string }) => void; }, @@ -42,6 +45,11 @@ vi.mock('./utils/kimiSessionTitleWatcher', () => ({ vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({ BaseLocalLauncher: class { + constructor(options: Record) { + harness.launcherOptions = options as { + launch: (abortSignal: AbortSignal) => Promise; + }; + } async run(): Promise<'exit'> { harness.locatorOptions?.onLocated({ sessionId: 'session_native', @@ -54,16 +62,23 @@ vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({ } })); +vi.mock('./kimiLocal', () => ({ + kimiLocal: vi.fn(async () => {}) +})); + import { kimiLocalLauncher } from './kimiLocalLauncher'; +import { kimiLocal } from './kimiLocal'; describe('kimiLocalLauncher title sync', () => { afterEach(() => { + harness.launcherOptions = null; harness.locatorOptions = null; harness.titleWatcherOptions = null; harness.titlesToEmit = []; harness.locatorCleanup.mockClear(); harness.scannerCleanup.mockClear(); harness.titleWatcherCleanup.mockClear(); + vi.mocked(kimiLocal).mockClear(); }); it('syncs the native state title and cleans up both session watchers', async () => { @@ -148,3 +163,29 @@ describe('kimiLocalLauncher title sync', () => { expect(sendClaudeSessionMessage).not.toHaveBeenCalled(); }); }); + + it('threads the session peer-tools snapshot into kimiLocal (#2056)', async () => { + const session = { + path: '/tmp/workspace', + sessionId: null, + startedBy: 'terminal' as const, + startingMode: 'local' as const, + queue: {}, + client: { + rpcHandlerManager: {}, + peerToolsDisabled: true + }, + getPermissionMode: () => 'default', + onSessionFound: () => {}, + sendUserMessage: () => {}, + sendAgentMessage: () => {}, + sendSessionEvent: () => {}, + recordLocalLaunchFailure: () => {} + }; + + await expect(kimiLocalLauncher(session as never, {})).resolves.toBe('exit'); + expect(harness.launcherOptions?.launch).toBeTypeOf('function'); + await harness.launcherOptions!.launch(new AbortController().signal); + + expect(kimiLocal).toHaveBeenCalledWith(expect.objectContaining({ peerToolsDisabled: true })); + }); diff --git a/cli/src/kimi/kimiLocalLauncher.ts b/cli/src/kimi/kimiLocalLauncher.ts index bcd6d8f2e0..41b4da254f 100644 --- a/cli/src/kimi/kimiLocalLauncher.ts +++ b/cli/src/kimi/kimiLocalLauncher.ts @@ -122,7 +122,8 @@ export async function kimiLocalLauncher( abort: abortSignal, model: opts.model, yolo: approval.yolo, - plan: approval.plan + plan: approval.plan, + peerToolsDisabled: session.client.peerToolsDisabled }); }, sendFailureMessage: (message) => {