diff --git a/.changeset/disable-session-title-egress.md b/.changeset/disable-session-title-egress.md new file mode 100644 index 000000000..339372d70 --- /dev/null +++ b/.changeset/disable-session-title-egress.md @@ -0,0 +1,10 @@ +--- +'@moonshot-ai/kimi-code': minor +--- + +Session titles are generated locally. The `chat_title` request, which sent an +excerpt of the conversation to the managed platform purely to produce a +display string, is no longer made. + +Sessions are still titled: the first prompt already supplies a local title, +and that text is run through the secret redactor first. diff --git a/HARDENING.md b/HARDENING.md index 1c6513a8c..ab04bdc43 100644 --- a/HARDENING.md +++ b/HARDENING.md @@ -343,3 +343,25 @@ rationale. Line numbers are from the commit that introduced the note and will dr **`staticEnableError: REMOTE_CONTROL_DISABLED_MESSAGE,`** > Upstream sets this only for a non-loopback bind or `--dangerous-bypass-auth`, which leaves `POST /api/v1/remote-control {"enabled":true}` working for a plain `kimi web`: anything holding the local server token could start a public tunnel with no terminal interaction and no second confirmation. Making it unconditional turns that route into a clean refusal. + +### `packages/oauth/src/managed-tools.ts` + +**`export async function fetchChatTitle(`** + +> Session-title generation is disabled in this fork. +> +> `chat_title` posts an excerpt of the conversation, the user's prompt and the assistant's reply, to the managed platform purely to produce a display string for the session list. It is the only call in the product that sends conversation content anywhere other than the configured model provider, so with a third-party model backend it is a second, unrelated destination for the same text. +> +> Upstream gated this behind an `auto_session_title` experimental flag. That flag no longer exists anywhere in the tree, and the bundled web UI fires the request unprompted on the first turn, so there was nothing left to turn off. +> +> Nothing is lost by refusing. `applyPromptMetadataUpdate` already sets a `replaceable` title locally from the first prompt via `titleFromPromptMetadataText`, and that text passes through the secret redactor first. Sessions stay titled; the title is a truncated prompt rather than a generated phrase. +> +> This is the chokepoint: it is the only function that sends `chat_title`, so refusing here also covers any caller a later upstream merge introduces. Upstream's implementation is kept as `fetchChatTitleRemote`, unreachable, so its tests keep running and upstream changes still merge cleanly. + +### `packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts` + +**`private async generateAndApply(`** + +> A second gate in front of the network seal, so the disabled path does no provider lookup and never asks the OAuth token provider for an access token. Upstream's body is kept as `generateAndApplyRemote`, unreachable. +> +> `composeTitleInput` is exported so its budget and digest-elision behaviour stays under test as a pure function, rather than being asserted through a request body that is no longer sent. diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 7bd030911..59534acbd 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -3,6 +3,7 @@ import { OAuthError, fetchChatTitle, kimiCodeToolsUrl, + SESSION_TITLE_EGRESS_DISABLED_MESSAGE, parseKimiCodeCustomHeaders, resolveKimiCodeRuntimeAuth, } from '@moonshot-ai/kimi-code-oauth'; @@ -88,6 +89,14 @@ export class SessionTitleService implements ISessionTitleService { } private async generateAndApply( + _chatContent: string, + _force: boolean, + ): Promise { + this.log.debug(SESSION_TITLE_EGRESS_DISABLED_MESSAGE); + return undefined; + } + + private async generateAndApplyRemote( chatContent: string, force: boolean, ): Promise { @@ -166,7 +175,7 @@ function titleInputFromPrompts(prompts: readonly string[]): string | undefined { .slice(0, MAX_TITLE_INPUT_LENGTH); } -async function composeTitleInput( +export async function composeTitleInput( promptSource: IAgentTitlePromptSource, source: SessionTitleSource, ): Promise { diff --git a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts index d8c52c7ab..db66104cc 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -27,7 +27,10 @@ import { type TitleTurnExcerpt, } from '#/session/sessionTitle/agentTitlePromptSource'; import { ISessionTitleService } from '#/session/sessionTitle/sessionTitle'; -import { SessionTitleService } from '#/session/sessionTitle/sessionTitleService'; +import { + composeTitleInput, + SessionTitleService, +} from '#/session/sessionTitle/sessionTitleService'; import { ISessionMetadata, type SessionMeta, @@ -229,366 +232,152 @@ describe('SessionTitleService', () => { vi.unstubAllEnvs(); }); - it('replaces the easy title with the generated one', async () => { - titlePrompts = ['帮我看一下这个 Go 的 nil pointer 报错']; + function promptSource(): IAgentTitlePromptSource { + return { + _serviceBrand: undefined, + firstUserPrompts: (limit) => promptSourceImpl(limit), + firstTurnExcerpt: async () => turnExcerpt, + digestExcerpt: async () => digestExcerpt, + }; + } - const title = await ix.get(ISessionTitleService).generateTitle(); + describe('session title egress is disabled in this fork', () => { + it('never calls the backend, whatever the source', async () => { + titlePrompts = ['先帮我搭一个 Vite 项目']; + turnExcerpt = { user: 'hello', assistant: 'hi there' }; + digestExcerpt = { turns: [{ user: 'a', assistant: 'b' }] }; - expect(title).toBe('生成的标题'); - expect(metadata.meta.title).toBe('生成的标题'); - expect(metadata.meta.titleKind).toBe('generated'); + for (const source of ['user_prompts', 'first_turn', 'digest'] as const) { + await expect( + ix.get(ISessionTitleService).generateTitle({ source }), + ).resolves.toBeUndefined(); + } - const [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { chat_content: 'user: 帮我看一下这个 Go 的 nil pointer 报错' }, + expect(fetchMock).not.toHaveBeenCalled(); }); - expect(new Headers(init?.headers as Record).get('authorization')).toBe( - 'Bearer test-token', - ); - const rebroadcast = events.published.find( - (event): event is SessionMetaUpdated => - event.type === 'session.meta.updated' && - (event as SessionMetaUpdated).payload.patch.title === '生成的标题', - ); - expect(rebroadcast).toBeDefined(); - }); + it('does not reach for an OAuth token either', async () => { + titlePrompts = ['hello']; - it('composes the title input from the recorded prompts in order', async () => { - titlePrompts = ['先帮我搭一个 Vite 项目', '加上路由', '现在配一下 ESLint']; + await ix.get(ISessionTitleService).generateTitle(); - await ix.get(ISessionTitleService).generateTitle(); - - const [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { - chat_content: 'user: 先帮我搭一个 Vite 项目\nuser: 加上路由\nuser: 现在配一下 ESLint', - }, + expect(tokenCalls).toEqual([]); + expect(resolvedOAuthRefs).toEqual([]); }); - }); - it('truncates each prompt to the per-prompt budget, keeping the head', async () => { - titlePrompts = ['很长的输入'.repeat(400), '第二条']; + it('refuses even when forced, and leaves a custom title intact', async () => { + await metadata.setTitle('我的标题'); + titlePrompts = ['hello']; - await ix.get(ISessionTitleService).generateTitle(); - - const [, init] = fetchMock.mock.calls[0]!; - const body = JSON.parse(init?.body as string) as { params: { chat_content: string } }; - expect(body.params.chat_content).toBe(`user: ${'很长的输入'.repeat(80)}\nuser: 第二条`); - }); - - it('returns unavailable when only a slash activation updated lastPrompt', async () => { - await metadata.update({ lastPrompt: '/compact' }); - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('does nothing without a managed OAuth provider', async () => { - delete providers['managed:kimi-code']; - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('never overwrites a custom title set while generation is in flight', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - - titlePrompts = ['hello']; - const generation = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; - await metadata.setTitle('user 取的标题'); - pendingFetch.resolve( - new Response(JSON.stringify({ title: '生成的标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - - await expect(generation).resolves.toBeUndefined(); - expect(metadata.meta.title).toBe('user 取的标题'); - expect(metadata.meta.titleKind).toBe('custom'); - }); + await expect( + ix.get(ISessionTitleService).generateTitle({ force: true }), + ).resolves.toBeUndefined(); - it('skips generation when the current title was already generated', async () => { - await metadata.setGeneratedTitleIfUncustomized('已生成的标题'); - titlePrompts = ['hello']; + expect(fetchMock).not.toHaveBeenCalled(); + expect((await metadata.read()).title).toBe('我的标题'); + }); - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - expect(metadata.meta.title).toBe('已生成的标题'); - }); + it('leaves the locally derived title in place', async () => { + await metadata.update({ title: 'Fix the parser', titleKind: 'replaceable' }); + titlePrompts = ['Fix the parser']; - it('force regenerates an already-generated title', async () => { - await metadata.setGeneratedTitleIfUncustomized('已生成的标题'); - titlePrompts = ['hello']; + await ix.get(ISessionTitleService).generateTitle(); - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true }), - ).resolves.toBe('生成的标题'); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(metadata.meta.title).toBe('生成的标题'); - expect(metadata.meta.titleKind).toBe('generated'); - }); - - it('force overwrites a custom title and drops its custom marking', async () => { - await metadata.setTitle('user 取的标题'); - titlePrompts = ['hello']; + const current = await metadata.read(); + expect(current.title).toBe('Fix the parser'); + expect(current.titleKind).toBe('replaceable'); + }); - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true }), - ).resolves.toBe('生成的标题'); - expect(metadata.meta.title).toBe('生成的标题'); - expect(metadata.meta.titleKind).toBe('generated'); - }); + it('publishes no metadata event, since nothing changed', async () => { + titlePrompts = ['hello']; - it('force still degrades when the backend request fails', async () => { - fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); - await metadata.setTitle('user 取的标题'); - titlePrompts = ['hello']; + await ix.get(ISessionTitleService).generateTitle(); - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true }), - ).resolves.toBeUndefined(); - expect(metadata.meta.title).toBe('user 取的标题'); - expect(metadata.meta.titleKind).toBe('custom'); + expect(events.published).toEqual([]); + }); }); - it('first_turn composes the opening prompt with the first reply, within budget', async () => { - turnExcerpt = { user: '最初的问题', assistant: '第一轮的回答' }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), - ).resolves.toBe('生成的标题'); + describe('composeTitleInput', () => { + it('composes the title input from the recorded prompts in order', async () => { + titlePrompts = ['先帮我搭一个 Vite 项目', '加上路由', '现在配一下 ESLint']; - const [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { chat_content: 'user: 最初的问题\nassistant: 第一轮的回答' }, + await expect(composeTitleInput(promptSource(), 'user_prompts')).resolves.toBe( + 'user: 先帮我搭一个 Vite 项目\nuser: 加上路由\nuser: 现在配一下 ESLint', + ); }); - }); - it('first_turn is strict: no assistant reply yet means unavailable', async () => { - turnExcerpt = { user: '只有问题' }; + it('truncates each prompt to the per-prompt budget, keeping the head', async () => { + titlePrompts = ['很长的输入'.repeat(400), '第二条']; - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), - ).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); + await expect(composeTitleInput(promptSource(), 'user_prompts')).resolves.toBe( + `user: ${'很长的输入'.repeat(80)}\nuser: 第二条`, + ); + }); - it('first_turn truncates each segment to its budget', async () => { - turnExcerpt = { user: '问'.repeat(500), assistant: '答'.repeat(1000) }; + it('first_turn composes the opening prompt with the first reply', async () => { + turnExcerpt = { user: '帮我修一下构建', assistant: '好的,我先看看配置' }; - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), - ).resolves.toBe('生成的标题'); + await expect(composeTitleInput(promptSource(), 'first_turn')).resolves.toBe( + 'user: 帮我修一下构建\nassistant: 好的,我先看看配置', + ); + }); - const [, init] = fetchMock.mock.calls[0]!; - const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) - .params.chat_content; - expect(content).toBe(`user: ${'问'.repeat(400)}\nassistant: ${'答'.repeat(300)}`); - }); + it('first_turn is strict: no assistant reply yet means unavailable', async () => { + turnExcerpt = { user: '帮我修一下构建' }; - it('digest composes every turn as interleaved user/assistant lines', async () => { - digestExcerpt = { - turns: [ - { user: '开场', assistant: '开场回答' }, - { user: '最新追问', assistant: '当前进展' }, - ], - }; + await expect(composeTitleInput(promptSource(), 'first_turn')).resolves.toBeUndefined(); + }); - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), - ).resolves.toBe('生成的标题'); + it('first_turn truncates each segment to its budget', async () => { + turnExcerpt = { user: '用'.repeat(600), assistant: '助'.repeat(600) }; - let [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { - chat_content: 'user: 开场\nassistant: 开场回答\nuser: 最新追问\nassistant: 当前进展', - }, + await expect(composeTitleInput(promptSource(), 'first_turn')).resolves.toBe( + `user: ${'用'.repeat(400)}\nassistant: ${'助'.repeat(300)}`, + ); }); - fetchMock.mockClear(); - digestExcerpt = { turns: [{ user: '开场', assistant: undefined }] }; - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true, source: 'digest' }), - ).resolves.toBe('生成的标题'); - [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { chat_content: 'user: 开场' }, + it('digest composes every turn as interleaved user/assistant lines', async () => { + digestExcerpt = { + turns: [ + { user: '第一问', assistant: '第一答' }, + { user: '第二问', assistant: '第二答' }, + ], + }; + + await expect(composeTitleInput(promptSource(), 'digest')).resolves.toBe( + 'user: 第一问\nassistant: 第一答\nuser: 第二问\nassistant: 第二答', + ); }); - }); - it('digest truncates each segment to its budget', async () => { - digestExcerpt = { - turns: [{ user: '问'.repeat(300), assistant: '答'.repeat(300) }], - }; + it('digest truncates each segment to its budget', async () => { + digestExcerpt = { turns: [{ user: '用'.repeat(400), assistant: '助'.repeat(400) }] }; - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), - ).resolves.toBe('生成的标题'); - - const [, init] = fetchMock.mock.calls[0]!; - const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) - .params.chat_content; - expect(content).toBe(`user: ${'问'.repeat(200)}\nassistant: ${'答'.repeat(200)}`); - }); - - it('digest elides the middle turns when the input exceeds the total budget', async () => { - digestExcerpt = { - turns: Array.from({ length: 30 }, (_, i) => ({ - user: `第${i}个${'问'.repeat(180)}`, - assistant: `第${i}个${'答'.repeat(180)}`, - })), - }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), - ).resolves.toBe('生成的标题'); - - const [, init] = fetchMock.mock.calls[0]!; - const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) - .params.chat_content; - expect(content.length).toBeLessThanOrEqual(3000); - expect(content.startsWith('user: 第0个')).toBe(true); - expect(content).toContain('\n...\n'); - expect(content.split('\n...\n')[1]?.startsWith('user: ')).toBe(true); - expect(content.endsWith(`assistant: 第29个${'答'.repeat(180)}`)).toBe(true); - }); - - it('digest is unavailable when the window yields no segments at all', async () => { - digestExcerpt = { turns: [] }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), - ).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('keeps the current title when the backend request fails', async () => { - fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); - titlePrompts = ['hello']; - await metadata.update({ title: 'hello', titleKind: 'replaceable' }); - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(metadata.meta.title).toBe('hello'); - expect(tokenCalls).toEqual([false]); - }); - - it('retries once with a force-refreshed token on a 401', async () => { - fetchMock.mockImplementationOnce(async () => new Response('', { status: 401 })); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBe('生成的标题'); - expect(metadata.meta.title).toBe('生成的标题'); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(tokenCalls).toEqual([false, true]); - }); - - it('gives up when the 401 persists after the force refresh', async () => { - fetchMock.mockImplementation(async () => new Response('', { status: 401 })); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(metadata.meta.title).toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(tokenCalls).toEqual([false, true]); - }); - - it('degrades when the force refresh after a 401 fails', async () => { - fetchMock.mockImplementationOnce(async () => new Response('', { status: 401 })); - forceTokenError = new OAuthUnauthorizedError('refresh rejected'); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(metadata.meta.title).toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(tokenCalls).toEqual([false, true]); - }); - - it('returns unavailable when the OAuth token is missing or revoked', async () => { - tokenError = new OAuthUnauthorizedError('re-login required'); - titlePrompts = ['hello']; - - const svc = ix.get(ISessionTitleService); - await expect(svc.generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('returns unavailable when OAuth token retrieval has an operational failure', async () => { - tokenError = new OAuthConnectionError('connection failed'); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('propagates unexpected token provider failures', async () => { - tokenError = new Error('unexpected failure'); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).rejects.toThrow( - 'unexpected failure', - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('includes environment custom headers', async () => { - vi.stubEnv('KIMI_CODE_CUSTOM_HEADERS', 'X-Proxy-Header: from-env\n'); - titlePrompts = ['hello']; - - await ix.get(ISessionTitleService).generateTitle(); - - const [, init] = fetchMock.mock.calls[0]!; - const headers = new Headers(init?.headers as Record); - expect(headers.get('x-proxy-header')).toBe('from-env'); - expect(headers.get('user-agent')).toBe('test'); - }); - - it('pairs the environment endpoint with its credential slot when it overrides persisted config', async () => { - vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.env.example.test/coding/v1'); - vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.env.example.test'); - titlePrompts = ['hello']; + await expect(composeTitleInput(promptSource(), 'digest')).resolves.toBe( + `user: ${'用'.repeat(200)}\nassistant: ${'助'.repeat(200)}`, + ); + }); - await ix.get(ISessionTitleService).generateTitle(); + it('digest is unavailable when the window yields no segments at all', async () => { + digestExcerpt = { turns: [] }; - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.env.example.test/coding/v1/tools'); - expect(resolvedOAuthRefs[0]).toMatchObject({ - storage: 'file', - oauthHost: 'https://auth.env.example.test', + await expect(composeTitleInput(promptSource(), 'digest')).resolves.toBeUndefined(); }); - expect(resolvedOAuthRefs[0]?.key).not.toBe(MANAGED_PROVIDER.oauth?.key); - }); - it('shares an in-flight generation between concurrent requests', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); + it('digest elides the middle turns when the input exceeds the total budget', async () => { + digestExcerpt = { + turns: Array.from({ length: 20 }, (_, index) => ({ + user: `问题${String(index)}`.padEnd(200, '啊'), + assistant: `回答${String(index)}`.padEnd(200, '嗯'), + })), + }; - titlePrompts = ['hello']; - const first = ix.get(ISessionTitleService).generateTitle(); - const second = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; + const composed = await composeTitleInput(promptSource(), 'digest'); - pendingFetch.resolve( - new Response(JSON.stringify({ title: '生成的标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - await expect(first).resolves.toBe('生成的标题'); - await expect(second).resolves.toBe('生成的标题'); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(composed).toBeDefined(); + expect(composed!.length).toBeLessThanOrEqual(3000); + expect(composed).toContain('...'); + expect(composed!.startsWith('user: 问题0')).toBe(true); + }); }); - it('returns unavailable without calling the backend when no prompt was seen', async () => { - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); }); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 5d6f20fe1..b370ad43c 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -620,7 +620,7 @@ describe('server-v2 /api/v1/sessions', () => { expect(generated.body.code).toBe(40923); }); - it('generates and persists a title through the public REST path', async () => { + it('never sends a chat excerpt through the public REST title path', async () => { await server?.close(); server = undefined; await writeFile( @@ -710,46 +710,25 @@ describe('server-v2 /api/v1/sessions', () => { expect(submitted.body.code).toBe(0); } - const generated = await postJson<{ title: string }>( - `/api/v1/sessions/${id}/title/generate`, - ); - expect(generated.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); - expect(toolsRequest).toEqual({ - method: 'chat_title', - params: { - chat_content: - 'user: first REST prompt\nuser: second REST prompt\nuser: third REST prompt', - }, - }); - - const got = await getJson(`/api/v1/sessions/${id}`); - expect(got.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); - - const again = await postJson(`/api/v1/sessions/${id}/title/generate`); - expect(again.body.code).toBe(40923); + const generated = await postJson(`/api/v1/sessions/${id}/title/generate`); + expect(generated.body.code).toBe(40923); + expect(toolsRequest).toBeUndefined(); - const forced = await postJson<{ title: string }>(`/api/v1/sessions/${id}/title/generate`, { + const forced = await postJson(`/api/v1/sessions/${id}/title/generate`, { force: true, }); - expect(forced.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); + expect(forced.body.code).toBe(40923); - await postJson(`/api/v1/sessions/${id}/profile`, { title: 'custom title' }); - const forcedCustom = await postJson<{ title: string }>( - `/api/v1/sessions/${id}/title/generate`, - { force: true }, - ); - expect(forcedCustom.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); - const afterCustom = await getJson(`/api/v1/sessions/${id}`); - expect(afterCustom.body.data.title).toBe('generated from REST'); - - const digested = await postJson<{ title: string }>(`/api/v1/sessions/${id}/title/generate`, { + const digested = await postJson(`/api/v1/sessions/${id}/title/generate`, { force: true, source: 'digest', }); - expect(digested.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); - expect(toolsRequest?.params.chat_content).toBe( - 'user: first REST prompt\nuser: second REST prompt\nuser: third REST prompt', - ); + expect(digested.body.code).toBe(40923); + + expect(toolsRequest).toBeUndefined(); + + const got = await getJson(`/api/v1/sessions/${id}`); + expect(got.body.data.title).toBe('first REST prompt'); }); it('returns session-not-found when generating a title for a missing session', async () => { diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 8f8295b37..3d8d67a73 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -428,7 +428,7 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring)', () => { } }); - it('emits one complete metadata event when a generated title is applied', async () => { + it('applies no generated title and sends no excerpt to the tools endpoint', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); @@ -507,25 +507,22 @@ key = "${titleOAuthRef.key}" }); const events: Event[] = []; const unsubscribe = session.onEvent((event) => { - if (event.type === 'session.meta.updated' && event.title === 'Generated title') { - events.push(event); - } + if (event.type === 'session.meta.updated') events.push(event); }); - await expect(harness.generateSessionTitle({ id: session.id })).resolves.toBe( - 'Generated title', - ); + await expect( + harness.generateSessionTitle({ id: session.id }), + ).resolves.toBeUndefined(); unsubscribe(); - expect(events).toEqual([ - expect.objectContaining({ - type: 'session.meta.updated', - sessionId: session.id, - agentId: 'main', - title: 'Generated title', - patch: { title: 'Generated title', isCustomTitle: false }, + expect(events).toEqual([]); + expect( + fetchSpy.mock.calls.filter(([input]) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + return url.endsWith('/tools'); }), - ]); + ).toEqual([]); } finally { await harness.close(); fetchSpy.mockRestore(); @@ -604,12 +601,10 @@ key = "${titleOAuthRef.key}" // The cold session is temporarily resumed for generation; block its // cleanup close inside the will-close hooks so the public resume below - // lands while the close is still in flight. - const titlePromise = client.generateSessionTitle({ id: 'ses_title_race' }); - await fetchStarted; + // lands while the close is still in flight. Title generation sends + // nothing in this fork, so the will-close hook is registered up front + // rather than after an outbound request starts. const sessionManager = client.engineAccessor.get(ISessionManager); - const tempHandle = sessionManager.get('ses_title_race'); - expect(tempHandle).toBeDefined(); let markCloseStarted!: () => void; let openCloseGate!: () => void; const closeStarted = new Promise((resolve) => { @@ -624,12 +619,7 @@ key = "${titleOAuthRef.key}" event.waitUntil(closeGate); }); - resolveFetch( - new Response(JSON.stringify({ title: 'Generated title' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); + const titlePromise = client.generateSessionTitle({ id: 'ses_title_race' }); await closeStarted; // The resume must queue behind the in-flight close instead of merging @@ -643,7 +633,7 @@ key = "${titleOAuthRef.key}" expect(order).toEqual([]); openCloseGate(); - await expect(titlePromise).resolves.toBe('Generated title'); + await expect(titlePromise).resolves.toBeUndefined(); const summary = await resumePromise; expect(summary.id).toBe('ses_title_race'); expect(order).toEqual(['resumed']); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 1611ee0f3..c497df229 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -130,7 +130,11 @@ export type { ManagedUsageResult, } from './managed-usage'; -export { fetchChatTitle, kimiCodeToolsUrl } from './managed-tools'; +export { + fetchChatTitle, + kimiCodeToolsUrl, + SESSION_TITLE_EGRESS_DISABLED_MESSAGE, +} from './managed-tools'; export type { FetchChatTitleError, FetchChatTitleOk, diff --git a/packages/oauth/src/managed-tools.ts b/packages/oauth/src/managed-tools.ts index 46bf05f65..13f410f72 100644 --- a/packages/oauth/src/managed-tools.ts +++ b/packages/oauth/src/managed-tools.ts @@ -30,7 +30,48 @@ export function kimiCodeToolsUrl(baseUrl?: string): string { return `${(baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, '')}/tools`; } +/** + * Session-title generation is disabled in this fork. + * + * `chat_title` posts an excerpt of the conversation — the user's prompt and + * the assistant's reply — to the managed platform purely to produce a display + * string for the session list. It is the only call in the product that sends + * conversation content anywhere other than the configured model provider, so + * with a third-party model backend it is a second, unrelated destination for + * the same text. + * + * Upstream gated this behind an `auto_session_title` experimental flag. That + * flag no longer exists anywhere in the tree, and the bundled web UI fires the + * request unprompted on the first turn, so there is nothing left to turn off. + * + * Nothing is lost by refusing. `applyPromptMetadataUpdate` already sets a + * `replaceable` title locally from the first prompt, via + * `titleFromPromptMetadataText`, and that text is run through the secret + * redactor first. Sessions stay titled; the title is a truncated prompt rather + * than a generated phrase. + * + * This is the chokepoint: it is the only function that sends `chat_title`, so + * refusing here also covers any caller a later upstream merge introduces. + */ +export const SESSION_TITLE_EGRESS_DISABLED_MESSAGE = + 'Session-title generation is disabled in this build: it would send a conversation ' + + 'excerpt to the managed platform. Sessions are titled locally from the first prompt.'; + export async function fetchChatTitle( + _url: string, + _accessToken: string, + _chatContent: string, + _opts: { timeoutMs?: number; headers?: Record; signal?: AbortSignal } = {}, +): Promise { + return { kind: 'error', message: SESSION_TITLE_EGRESS_DISABLED_MESSAGE }; +} + +/** + * Upstream's implementation, unchanged and unreachable in this fork. Kept so + * upstream's own tests keep running against it and upstream changes still + * merge cleanly rather than conflicting against a deleted function. + */ +export async function fetchChatTitleRemote( url: string, accessToken: string, chatContent: string, diff --git a/packages/oauth/test/managed-tools.test.ts b/packages/oauth/test/managed-tools.test.ts index 53de6cc6f..7a8c752c8 100644 --- a/packages/oauth/test/managed-tools.test.ts +++ b/packages/oauth/test/managed-tools.test.ts @@ -8,7 +8,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchChatTitle, kimiCodeToolsUrl } from '../src/managed-tools'; +import { + fetchChatTitle, + fetchChatTitleRemote, + kimiCodeToolsUrl, + SESSION_TITLE_EGRESS_DISABLED_MESSAGE, +} from '../src/managed-tools'; afterEach(() => { vi.unstubAllGlobals(); @@ -26,7 +31,7 @@ describe('kimiCodeToolsUrl', () => { }); }); -describe('fetchChatTitle', () => { +describe('fetchChatTitleRemote (unreachable upstream implementation)', () => { it('POSTs the chat_title method with bearer auth and returns the title on 200', async () => { const fetchMock = vi.fn( async () => @@ -37,7 +42,7 @@ describe('fetchChatTitle', () => { ); vi.stubGlobal('fetch', fetchMock); - const result = await fetchChatTitle( + const result = await fetchChatTitleRemote( 'https://api.example/tools', 'access-token', 'user: nil pointer 报错', @@ -70,7 +75,7 @@ describe('fetchChatTitle', () => { ); vi.stubGlobal('fetch', fetchMock); - await fetchChatTitle('https://api.example/tools', 'access-token', 'user: hi', { + await fetchChatTitleRemote('https://api.example/tools', 'access-token', 'user: hi', { headers: { authorization: 'Bearer wrong-token', aCcEpT: 'text/plain', @@ -99,7 +104,7 @@ describe('fetchChatTitle', () => { ), ); - const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + const result = await fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi'); expect(result).toEqual({ kind: 'ok', title: '标题' }); }); @@ -116,7 +121,7 @@ describe('fetchChatTitle', () => { ), ); - const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + const result = await fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi'); expect(result).toEqual({ kind: 'error', @@ -130,7 +135,7 @@ describe('fetchChatTitle', () => { vi.fn(async () => new Response('', { status: 401 })), ); - const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + const result = await fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi'); expect(result.kind).toBe('error'); if (result.kind !== 'error') return; @@ -150,7 +155,7 @@ describe('fetchChatTitle', () => { ), ); - const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + const result = await fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi'); expect(result).toEqual({ kind: 'error', status: 400, message: 'title rejected' }); }); @@ -170,7 +175,7 @@ describe('fetchChatTitle', () => { ), ); - const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', { + const result = await fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi', { timeoutMs: 5, }); @@ -188,7 +193,7 @@ describe('fetchChatTitle', () => { }), ); - const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + const result = await fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi'); expect(result.kind).toBe('error'); if (result.kind !== 'error') return; @@ -216,7 +221,7 @@ describe('fetchChatTitle', () => { ); const external = new AbortController(); - const resultPromise = fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', { + const resultPromise = fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi', { signal: external.signal, timeoutMs: 60_000, }); @@ -237,7 +242,7 @@ describe('fetchChatTitle', () => { const external = new AbortController(); external.abort(); - const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', { + const result = await fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi', { signal: external.signal, }); @@ -261,7 +266,7 @@ describe('fetchChatTitle', () => { const addSpy = vi.spyOn(external.signal, 'addEventListener'); const removeSpy = vi.spyOn(external.signal, 'removeEventListener'); - await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', { + await fetchChatTitleRemote('https://api.example/tools', 'tok', 'user: hi', { signal: external.signal, }); @@ -270,3 +275,30 @@ describe('fetchChatTitle', () => { expect(removeSpy.mock.calls[0]?.[1]).toBe(addSpy.mock.calls[0]?.[1]); }); }); + +describe('session title egress is disabled in this fork', () => { + it('refuses without making a request', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const result = await fetchChatTitle( + 'https://api.example/tools', + 'access-token', + 'user: something private\nassistant: also private', + ); + + expect(result).toEqual({ kind: 'error', message: SESSION_TITLE_EGRESS_DISABLED_MESSAGE }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('refuses for every caller, whatever the url or token', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + for (const url of ['https://api.kimi.com/coding/v1/tools', 'https://api.kimi.ai/coding/v1/tools']) { + const result = await fetchChatTitle(url, 'tok', 'user: hi'); + expect(result.kind).toBe('error'); + } + expect(fetchSpy).not.toHaveBeenCalled(); + }); +});