diff --git a/.gitignore b/.gitignore index cb894b60070..202070e7330 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ tmp/ # Auto-generated computer-use marker can also appear under nested packages. **/.qwen/computer-use/ .playwright-mcp/ +pnpm-lock.yaml diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index f5f269ab0b6..40e29eb4125 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -366,6 +366,7 @@ describe('qwen serve — capabilities envelope', () => { 'permission_mediation', 'non_blocking_prompt', 'session_language', + 'session_runtime_context', 'session_rewind', 'workspace_hooks', 'session_hooks', diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 72aa0e0712f..94155db8a61 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5977,6 +5977,35 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async setSessionRuntimeContext(sessionId, entries, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); + + const response = (await Promise.race([ + withTimeout( + entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, + { sessionId, entries }, + ), + initTimeoutMs, + SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, + ), + getTransportClosedReject(entry), + ])) as { + keys: string[]; + rejected?: Array<{ key: string; reason: string }>; + }; + + return { + sessionId, + keys: response.keys, + rejected: response.rejected ?? [], + }; + }, + async generateSessionRecap(sessionId, _context) { // Thin pass-through to `qwen/control/session/ // recap` — the ACP child runs `generateSessionRecap` against the diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index b47f6949752..71c283c6330 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -995,6 +995,21 @@ export interface AcpSessionBridge { persisted: boolean; }>; + /** + * Set, update, or remove runtime context entries on a live session. + * Entries are injected as per-turn blocks on the + * next model call. Passing an empty string for a value removes that key. + */ + setSessionRuntimeContext( + sessionId: string, + entries: Record, + context?: BridgeClientRequestContext, + ): Promise<{ + sessionId: string; + keys: string[]; + rejected: Array<{ key: string; reason: string }>; + }>; + /** * Generate a one-sentence "where did I leave off" recap of a live * session. Forwards through `qwen/control/session/recap`, which diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index e923c627c5a..76269ab743e 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -154,6 +154,7 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionGoalClear: 'qwen/control/session/goal/clear', workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add', workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove', + sessionRuntimeContext: 'qwen/control/session/runtime_context', workspaceReload: 'qwen/control/workspace/reload', workspaceExtensionsRefresh: 'qwen/control/workspace/extensions/refresh', /** diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index cd82042534a..503bc2f15d6 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11153,3 +11153,228 @@ describe('deliverClientMcpMessage — reverse tool channel (#5626)', () => { }); }); }); + +describe('sessionRuntimeContext handler', () => { + let capturedAgentFactory: + | ((conn: { closed: Promise }) => { + initialize: (args: Record) => Promise; + newSession: (args: Record) => Promise; + extMethod: ( + method: string, + args: Record, + ) => Promise>; + }) + | undefined; + + let processExitSpy: MockInstance; + let stdinDestroySpy: MockInstance; + let stdoutDestroySpy: MockInstance; + + const mockConnectionState = { + promise: undefined as unknown as Promise, + resolve: undefined as unknown as () => void, + reset() { + this.promise = new Promise((r) => { + this.resolve = r; + }); + }, + }; + + const runtimeCtxMap = new Map(); + + function makeRuntimeCtxConfig(overrides: Record = {}) { + return { + initialize: vi.fn().mockResolvedValue(undefined), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + getModel: vi.fn().mockReturnValue('m'), + getModelsConfig: vi.fn().mockReturnValue({ + getCurrentAuthType: vi.fn().mockReturnValue('api-key'), + syncAfterAuthRefresh: vi.fn(), + }), + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn().mockResolvedValue(undefined), + getTargetDir: vi.fn().mockReturnValue('/tmp'), + getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getAvailableModels: vi.fn().mockReturnValue([]), + getModes: vi.fn().mockReturnValue([]), + getApprovalMode: vi.fn().mockReturnValue('default'), + getSessionId: vi.fn().mockReturnValue('rt-sid'), + getAuthType: vi.fn().mockReturnValue('api-key'), + getAllConfiguredModels: vi.fn().mockReturnValue([]), + getGeminiClient: vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + waitForMcpReady: vi.fn().mockResolvedValue(undefined), + refreshSystemInstruction: vi.fn().mockResolvedValue(undefined), + }), + getFileSystemService: vi.fn().mockReturnValue(undefined), + setFileSystemService: vi.fn(), + getHookSystem: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(true), + hasHooksForEvent: vi.fn().mockReturnValue(false), + getWorkspaceContext: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), + getRuntimeContext: vi.fn().mockReturnValue(runtimeCtxMap), + setRuntimeContextEntry: vi + .fn() + .mockImplementation((key: string, value: string) => { + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key)) return false; + if (Buffer.byteLength(value, 'utf8') > 32 * 1024) return false; + if (!runtimeCtxMap.has(key) && runtimeCtxMap.size >= 16) return false; + runtimeCtxMap.set(key, value); + return true; + }), + removeRuntimeContextEntry: vi.fn().mockImplementation((key: string) => { + runtimeCtxMap.delete(key); + }), + ...overrides, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + runtimeCtxMap.clear(); + mockConnectionState.reset(); + capturedAgentFactory = undefined; + + vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => { + capturedAgentFactory = factory as typeof capturedAgentFactory; + return { + get closed() { + return mockConnectionState.promise; + }, + } as unknown as InstanceType; + }); + + processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as unknown as typeof process.exit); + stdinDestroySpy = vi + .spyOn(process.stdin, 'destroy') + .mockImplementation(() => process.stdin); + stdoutDestroySpy = vi + .spyOn(process.stdout, 'destroy') + .mockImplementation(() => process.stdout); + }); + + afterEach(() => { + mockConnectionState.resolve(); + processExitSpy.mockRestore(); + stdinDestroySpy.mockRestore(); + stdoutDestroySpy.mockRestore(); + }); + + async function setupAgent() { + const cfg = makeRuntimeCtxConfig(); + + vi.mocked(loadSettings).mockReturnValue({ + merged: { mcpServers: {} }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings); + + vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); + + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue('rt-sid'), + getConfig: vi.fn().mockReturnValue(cfg), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as Session, + ); + + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({ + availableCommands: [], + availableSkills: [], + }); + + const bootConfig = makeRuntimeCtxConfig(); + runAcpAgent( + bootConfig as unknown as Config, + { merged: { mcpServers: {} } } as unknown as LoadedSettings, + {} as CliArgs, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + closed: mockConnectionState.promise, + }); + await agent.initialize({}); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + return agent; + } + + it('sets entries and returns applied keys', async () => { + const agent = await setupAgent(); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, + { + sessionId: 'rt-sid', + entries: { operator: 'Alice', rules: 'no-prod' }, + }, + ); + expect(result['keys']).toEqual(['operator', 'rules']); + expect(result['rejected']).toEqual([]); + expect(runtimeCtxMap.get('operator')).toBe('Alice'); + }); + + it('removes entries with empty string values', async () => { + runtimeCtxMap.set('old-key', 'stale'); + const agent = await setupAgent(); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, + { sessionId: 'rt-sid', entries: { 'old-key': '' } }, + ); + expect(result['keys']).toEqual(['old-key']); + expect(runtimeCtxMap.has('old-key')).toBe(false); + }); + + it('rejects non-string values', async () => { + const agent = await setupAgent(); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, + { sessionId: 'rt-sid', entries: { bad: 123 } }, + ); + expect(result['keys']).toEqual([]); + expect( + (result['rejected'] as Array<{ key: string; reason: string }>)[0], + ).toEqual({ + key: 'bad', + reason: 'value_not_string', + }); + }); + + it('rejects invalid keys', async () => { + const agent = await setupAgent(); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, + { sessionId: 'rt-sid', entries: { 'invalid key!': 'value' } }, + ); + expect(result['keys']).toEqual([]); + expect( + (result['rejected'] as Array<{ key: string; reason: string }>)[0]?.reason, + ).toBe('invalid_key'); + }); + + it('throws on missing sessionId', async () => { + const agent = await setupAgent(); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, { + entries: { a: 'b' }, + }), + ).rejects.toThrow(/sessionId/); + }); + + it('throws on invalid entries shape', async () => { + const agent = await setupAgent(); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, { + sessionId: 'rt-sid', + entries: 'not-an-object', + }), + ).rejects.toThrow(/entries/); + }); +}); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 304cbc9b553..5cb59ee1083 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -7191,6 +7191,55 @@ class QwenAgent implements Agent { return { language: resolvedLanguage, outputLanguage, refreshed }; } + case SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext: { + const sessionId = params['sessionId']; + const entries = params['entries']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + if ( + typeof entries !== 'object' || + entries === null || + Array.isArray(entries) + ) { + throw RequestError.invalidParams( + undefined, + '`entries` must be a non-null object', + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const appliedKeys: string[] = []; + const rejected: Array<{ key: string; reason: string }> = []; + for (const [key, value] of Object.entries( + entries as Record, + )) { + if (typeof value !== 'string') { + rejected.push({ key, reason: 'value_not_string' }); + continue; + } + if (value === '') { + if (config.getRuntimeContext().has(key)) { + config.removeRuntimeContextEntry(key); + appliedKeys.push(key); + } + } else if (config.setRuntimeContextEntry(key, value)) { + appliedKeys.push(key); + } else if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key)) { + rejected.push({ key, reason: 'invalid_key' }); + } else if ( + Buffer.byteLength(value, 'utf8') > 32 * 1024 + ) { + rejected.push({ key, reason: 'value_too_large' }); + } else { + rejected.push({ key, reason: 'capacity_full' }); + } + } + return { keys: appliedKeys, rejected }; + } case SERVE_CONTROL_EXT_METHODS.sessionRecap: { // Generate a one-sentence "where did I leave off" summary. // Best-effort: returns `null` on short history or model failure. diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 1866f3d9b2b..2ccd9512f38 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -253,6 +253,7 @@ export const SERVE_CAPABILITY_REGISTRY = { writer_idle_timeout: { since: 'v1' }, non_blocking_prompt: { since: 'v1' }, session_language: { since: 'v1' }, + session_runtime_context: { since: 'v1' }, session_rewind: { since: 'v1' }, workspace_hooks: { since: 'v1' }, session_hooks: { since: 'v1' }, diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 625da4e0c9a..144eb78879c 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -2832,4 +2832,48 @@ export function registerSessionRoutes( }, ), ); + + app.post( + '/session/:id/runtime-context', + mutate(), + withMutableSession( + 'POST /session/:id/runtime-context', + async (req, res, sessionId) => { + const body = safeBody(req); + const entries = body['entries']; + + if ( + typeof entries !== 'object' || + entries === null || + Array.isArray(entries) + ) { + res.status(400).json({ + error: + '`entries` is required and must be a non-null object mapping string keys to string values', + code: 'invalid_entries', + }); + return; + } + + const serialized = JSON.stringify(entries); + if (Buffer.byteLength(serialized, 'utf8') > 32 * 1024) { + res.status(413).json({ + error: 'runtime context payload exceeds 32 KiB limit', + code: 'payload_too_large', + }); + return; + } + + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + + const response = await bridge.setSessionRuntimeContext( + sessionId, + entries as Record, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(response); + }, + ), + ); } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 6f91b5ac83e..381bc1ba02a 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -302,6 +302,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'permission_mediation', 'non_blocking_prompt', 'session_language', + 'session_runtime_context', 'session_rewind', 'workspace_hooks', 'session_hooks', @@ -347,6 +348,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'permission_mediation' && f !== 'non_blocking_prompt' && f !== 'session_language' && + f !== 'session_runtime_context' && f !== 'session_rewind' && f !== 'workspace_hooks' && f !== 'session_hooks' && @@ -376,6 +378,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'writer_idle_timeout', 'non_blocking_prompt', 'session_language', + 'session_runtime_context', 'session_rewind', 'workspace_hooks', 'session_hooks', @@ -1644,6 +1647,19 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return setApprovalModeImpl(sessionId, mode, o, context); }, + async setSessionRuntimeContext( + sessionId: string, + entries: Record, + _context?: { clientId?: string }, + ) { + return { + sessionId, + keys: Object.keys(entries).filter( + (k) => typeof entries[k] === 'string', + ), + rejected: [] as Array<{ key: string; reason: string }>, + }; + }, async generateSessionRecap(sessionId, context) { generateSessionRecapCalls.push({ sessionId, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 38eb7e7a502..2e327ddca2b 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -15,8 +15,6 @@ import { APPROVAL_MODE_INFO, MCPServerConfig, TrustGateError, - matchesServerPattern, - matchesAnyServerPattern, } from './config.js'; import { Storage } from './storage.js'; import { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from '../services/loopDetectionService.js'; @@ -58,12 +56,6 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js'; import type { LoadServerHierarchicalMemoryOptions } from '../utils/memoryDiscovery.js'; import { readAutoMemoryIndex } from '../memory/store.js'; -import { - rebuildTeamAutoMemoryIndex, - TeamMemoryRootSecurityError, -} from '../memory/indexer.js'; -import { syncTeamMemory } from '../memory/team-memory-sync.js'; -import { getTeamMemoryShareabilityWarning } from '../memory/team-memory-git-status.js'; import * as runtimeStatus from '../utils/runtimeStatus.js'; import { ExtensionManager } from '../extension/extensionManager.js'; import { SkillManager } from '../skills/skill-manager.js'; @@ -159,20 +151,6 @@ vi.mock('../memory/store.js', () => ({ readAutoMemoryIndex: vi.fn().mockResolvedValue(null), readUserAutoMemoryIndex: vi.fn().mockResolvedValue(null), })); -vi.mock('../memory/indexer.js', async (importActual) => ({ - // Keep the real exports (notably TeamMemoryRootSecurityError, which the sync - // gate distinguishes via instanceof) and override only the rebuild. - ...(await importActual()), - rebuildTeamAutoMemoryIndex: vi.fn().mockResolvedValue(null), -})); -vi.mock('../memory/team-memory-sync.js', () => ({ - syncTeamMemory: vi - .fn() - .mockResolvedValue({ committed: false, pulled: false, pushed: false }), -})); -vi.mock('../memory/team-memory-git-status.js', () => ({ - getTeamMemoryShareabilityWarning: vi.fn().mockReturnValue(null), -})); vi.mock('../hooks/index.js', () => { const HookSystemMock = vi.fn(); @@ -349,92 +327,6 @@ vi.mock('../core/toolHookTriggers.js', () => ({ fireNotificationHook: vi.fn().mockResolvedValue({}), })); -describe('matchesServerPattern', () => { - it('exact match when no glob characters', () => { - expect(matchesServerPattern('puppeteer', 'puppeteer')).toBe(true); - expect(matchesServerPattern('puppeteer', 'playwright')).toBe(false); - }); - - it('* matches any sequence including empty', () => { - expect(matchesServerPattern('puppeteer', '*puppeteer*')).toBe(true); - expect(matchesServerPattern('my-puppeteer-server', '*puppeteer*')).toBe( - true, - ); - expect(matchesServerPattern('playwright', '*puppeteer*')).toBe(false); - expect(matchesServerPattern('anything', '*')).toBe(true); - expect(matchesServerPattern('prefix-suffix', 'prefix*')).toBe(true); - expect(matchesServerPattern('prefix-suffix', '*suffix')).toBe(true); - }); - - it('? matches exactly one character', () => { - expect(matchesServerPattern('abc', 'a?c')).toBe(true); - expect(matchesServerPattern('ac', 'a?c')).toBe(false); - expect(matchesServerPattern('axc', 'a?c')).toBe(true); - }); - - it('escapes regex special characters', () => { - expect(matchesServerPattern('my.server', 'my.server')).toBe(true); - expect(matchesServerPattern('myXserver', 'my.server')).toBe(false); - expect(matchesServerPattern('a+b', 'a+b')).toBe(true); - expect(matchesServerPattern('a^b', 'a^b')).toBe(true); - expect(matchesServerPattern('a$b', 'a$b')).toBe(true); - expect(matchesServerPattern('aXb', 'a$b')).toBe(false); - }); - - it('combines glob with exact segments', () => { - expect(matchesServerPattern('foo-bar-baz', 'foo-*-baz')).toBe(true); - expect(matchesServerPattern('foo-bar-qux', 'foo-*-baz')).toBe(false); - }); - - it('handles empty name', () => { - expect(matchesServerPattern('', '*')).toBe(true); - expect(matchesServerPattern('', '?')).toBe(false); - expect(matchesServerPattern('', '')).toBe(true); - }); - - it('handles consecutive * in pattern', () => { - expect(matchesServerPattern('puppeteer', '**puppeteer**')).toBe(true); - expect(matchesServerPattern('abc', 'a**c')).toBe(true); - }); - - it('handles ? at pattern boundaries', () => { - expect(matchesServerPattern('abc', '?bc')).toBe(true); - expect(matchesServerPattern('abc', 'ab?')).toBe(true); - expect(matchesServerPattern('abc', '???')).toBe(true); - expect(matchesServerPattern('ab', '???')).toBe(false); - }); - - it('rejects when pattern is longer than name', () => { - expect(matchesServerPattern('ab', 'a*b*c')).toBe(false); - expect(matchesServerPattern('abc', 'a*b*c')).toBe(true); - }); -}); - -describe('matchesAnyServerPattern', () => { - it('returns false for undefined or empty list', () => { - expect(matchesAnyServerPattern('puppeteer', undefined)).toBe(false); - expect(matchesAnyServerPattern('puppeteer', [])).toBe(false); - }); - - it('matches if any pattern matches', () => { - expect( - matchesAnyServerPattern('puppeteer', ['playwright', '*puppeteer*']), - ).toBe(true); - expect( - matchesAnyServerPattern('chrome', ['playwright', '*puppeteer*']), - ).toBe(false); - }); - - it('works with mixed exact and glob patterns', () => { - expect( - matchesAnyServerPattern('playwright', ['playwright', '*puppeteer*']), - ).toBe(true); - expect( - matchesAnyServerPattern('my-puppeteer', ['playwright', '*puppeteer*']), - ).toBe(true); - }); -}); - describe('Server Config (config.ts)', () => { const MODEL = 'qwen3-coder-plus'; @@ -900,7 +792,6 @@ describe('Server Config (config.ts)', () => { ).toBe(false); }); }); - it('should store a system prompt override', () => { const config = new Config({ ...baseParams, @@ -1651,125 +1542,6 @@ describe('Server Config (config.ts)', () => { }); expect(config.getAllowedMcpServers()).toEqual(['y']); }); - - it('getMcpServers filters by glob pattern in allowedMcpServers', async () => { - const config = new Config({ - ...baseParams, - mcpServers: { - puppeteer: srvA, - 'my-puppeteer-server': srvB, - playwright: srvA, - }, - }); - config.setAllowedMcpServers(['*puppeteer*']); - const result = config.getMcpServers(); - expect(Object.keys(result!)).toEqual([ - 'puppeteer', - 'my-puppeteer-server', - ]); - expect(Object.keys(result!)).not.toContain('playwright'); - }); - - it('isMcpServerDisabled supports glob patterns in excludedMcpServers', () => { - const config = new Config({ - ...baseParams, - mcpServers: { - puppeteer: srvA, - 'my-puppeteer': srvA, - playwright: srvB, - }, - }); - config.setExcludedMcpServers(['*puppeteer*']); - expect(config.isMcpServerDisabled('puppeteer')).toBe(true); - expect(config.isMcpServerDisabled('my-puppeteer')).toBe(true); - expect(config.isMcpServerDisabled('playwright')).toBe(false); - expect(config.getMcpServers()!['puppeteer']).toBeDefined(); - expect(config.getMcpServers()!['my-puppeteer']).toBeDefined(); - }); - - it('getMcpServerUnavailableReason classifies by glob match', async () => { - const config = new Config({ - ...baseParams, - mcpServers: { - puppeteer: srvA, - playwright: srvB, - chrome: srvA, - }, - }); - await config.reinitializeMcpServers({ - puppeteer: srvA, - playwright: srvB, - chrome: srvA, - }); - - config.setAllowedMcpServers(['play*']); - expect(config.getMcpServerUnavailableReason('puppeteer')).toBe( - 'not_allowed', - ); - expect( - config.getMcpServerUnavailableReason('playwright'), - ).toBeUndefined(); - - // Clear allow-list so the excluded check is reached. - config.setAllowedMcpServers(undefined); - config.setExcludedMcpServers(['*chrome*']); - expect(config.getMcpServerUnavailableReason('chrome')).toBe('excluded'); - }); - - it('exclude takes precedence over allow with glob patterns', async () => { - const config = new Config({ - ...baseParams, - mcpServers: { puppeteer: srvA, playwright: srvB }, - }); - await config.reinitializeMcpServers({ - puppeteer: srvA, - playwright: srvB, - }); - - config.setAllowedMcpServers(['*']); - config.setExcludedMcpServers(['puppeteer']); - expect(config.getMcpServerUnavailableReason('puppeteer')).toBe( - 'excluded', - ); - expect( - config.getMcpServerUnavailableReason('playwright'), - ).toBeUndefined(); - }); - - it('exclude takes precedence when both lists use globs', async () => { - const config = new Config({ - ...baseParams, - mcpServers: { puppeteer: srvA, playwright: srvB }, - }); - await config.reinitializeMcpServers({ - puppeteer: srvA, - playwright: srvB, - }); - - config.setAllowedMcpServers(['*puppeteer*']); - config.setExcludedMcpServers(['puppeteer']); - expect(config.getMcpServerUnavailableReason('puppeteer')).toBe( - 'excluded', - ); - expect(config.isMcpServerDisabled('puppeteer')).toBe(true); - }); - - it('getBlockedMcpServers returns servers not matching allowed glob', () => { - const config = new Config({ - ...baseParams, - mcpServers: { - puppeteer: srvA, - 'my-puppeteer': srvA, - playwright: srvB, - }, - }); - config.setAllowedMcpServers(['*puppeteer*']); - const blocked = config.getBlockedMcpServers(); - const blockedNames = blocked.map((s) => s.name); - expect(blockedNames).toContain('playwright'); - expect(blockedNames).not.toContain('puppeteer'); - expect(blockedNames).not.toContain('my-puppeteer'); - }); }); describe('MemoryPressureMonitor isolation', () => { @@ -3436,180 +3208,6 @@ describe('Server Config (config.ts)', () => { expect(config.getUserMemory()).toContain('[Project Memory](project.md)'); }); - it('refreshHierarchicalMemory should not load team memory from untrusted workspaces', async () => { - const config = new Config({ ...baseParams, enableTeamMemory: true }); - vi.spyOn(config, 'isTrustedFolder').mockReturnValue(false); - vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ - memoryContent: '--- Context from: QWEN.md ---\nProject rules', - fileCount: 1, - ruleCount: 0, - conditionalRules: [], - projectRoot: '/tmp', - }); - vi.mocked(rebuildTeamAutoMemoryIndex).mockResolvedValue( - '# Team Memory\n\n- [Shared](shared.md)', - ); - - await config.refreshHierarchicalMemory(); - - expect(rebuildTeamAutoMemoryIndex).not.toHaveBeenCalled(); - expect(config.getUserMemory()).not.toContain('Team Memory'); - // The shareability check is gated on the active tier, so an inactive - // (untrusted) tier must never probe git. - expect(getTeamMemoryShareabilityWarning).not.toHaveBeenCalled(); - }); - - it('refreshHierarchicalMemory must not sync when the team-root safety check rejects', async () => { - // The indexer THROWS when the team root is a symlink that could redirect the - // committed index outside the repo. Sync must respect that refusal: it must - // never git add/commit/push a dir that failed the safety check. - const prevSync = process.env['QWEN_CODE_MEMORY_TEAM_SYNC']; - process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = '1'; - try { - const config = new Config({ - ...baseParams, - enableTeamMemory: true, - enableTeamMemorySync: true, - }); - vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); - vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ - memoryContent: '--- Context from: QWEN.md ---\nProject rules', - fileCount: 1, - ruleCount: 0, - conditionalRules: [], - projectRoot: '/tmp', - }); - // Mirror the indexer's symlink-escape rejection: a SECURITY failure, which - // is the only class that blocks sync (see indexer.ts). - vi.mocked(rebuildTeamAutoMemoryIndex).mockRejectedValueOnce( - new TeamMemoryRootSecurityError( - 'Refusing to write team memory index: /tmp/.qwen/team-memory is a ' + - 'symlink, which could redirect the committed index outside the repository.', - ), - ); - - await config.refreshHierarchicalMemory(); - - // Gate proof: sync is enabled, yet the security rejection must skip it - // entirely. Stop treating TeamMemoryRootSecurityError as blocking and this - // assertion fails. - expect(rebuildTeamAutoMemoryIndex).toHaveBeenCalledTimes(1); - expect(syncTeamMemory).not.toHaveBeenCalled(); - } finally { - if (prevSync === undefined) { - delete process.env['QWEN_CODE_MEMORY_TEAM_SYNC']; - } else { - process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = prevSync; - } - } - }); - - it('still syncs when the team-index rebuild fails for an OPERATIONAL reason', async () => { - // An EACCES/ENOSPC/EPERM rebuild failure is not a security escape, so it must - // NOT permanently gate legitimate sync — it self-corrects on the next - // successful rebuild. Only TeamMemoryRootSecurityError blocks sync. - const prevSync = process.env['QWEN_CODE_MEMORY_TEAM_SYNC']; - process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = '1'; - try { - const config = new Config({ - ...baseParams, - enableTeamMemory: true, - enableTeamMemorySync: true, - }); - vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); - vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ - memoryContent: '--- Context from: QWEN.md ---\nProject rules', - fileCount: 1, - ruleCount: 0, - conditionalRules: [], - projectRoot: '/tmp', - }); - // A plain Error stands in for an operational IO failure (e.g. EACCES). - const operationalError = Object.assign( - new Error('EACCES: permission denied, lstat'), - { - code: 'EACCES', - }, - ); - vi.mocked(rebuildTeamAutoMemoryIndex).mockRejectedValueOnce( - operationalError, - ); - - await config.refreshHierarchicalMemory(); - - // Not security-gated: sync still runs despite the operational failure. - expect(rebuildTeamAutoMemoryIndex).toHaveBeenCalledTimes(1); - expect(syncTeamMemory).toHaveBeenCalledTimes(1); - } finally { - if (prevSync === undefined) { - delete process.env['QWEN_CODE_MEMORY_TEAM_SYNC']; - } else { - process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = prevSync; - } - } - }); - - it('syncs when the rebuild succeeds and sync is enabled (positive gate)', async () => { - // Complement to the negative branches: a successful rebuild on a trusted - // folder with sync enabled MUST call syncTeamMemory. Inverting or removing - // the sync condition is caught here. - const prevSync = process.env['QWEN_CODE_MEMORY_TEAM_SYNC']; - process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = '1'; - try { - const config = new Config({ - ...baseParams, - enableTeamMemory: true, - enableTeamMemorySync: true, - }); - vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); - vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ - memoryContent: '--- Context from: QWEN.md ---\nProject rules', - fileCount: 1, - ruleCount: 0, - conditionalRules: [], - projectRoot: '/tmp', - }); - vi.mocked(rebuildTeamAutoMemoryIndex).mockResolvedValueOnce( - '# Team Memory\n\n- [Shared](shared.md)', - ); - - await config.refreshHierarchicalMemory(); - - expect(rebuildTeamAutoMemoryIndex).toHaveBeenCalledTimes(1); - expect(syncTeamMemory).toHaveBeenCalledTimes(1); - } finally { - if (prevSync === undefined) { - delete process.env['QWEN_CODE_MEMORY_TEAM_SYNC']; - } else { - process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = prevSync; - } - } - }); - - it('refreshHierarchicalMemory surfaces a one-time warning when team memory is not git-shareable', async () => { - const config = new Config({ ...baseParams, enableTeamMemory: true }); - vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); - vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ - memoryContent: '--- Context from: QWEN.md ---\nProject rules', - fileCount: 1, - ruleCount: 0, - conditionalRules: [], - projectRoot: '/tmp', - }); - vi.mocked(getTeamMemoryShareabilityWarning).mockReturnValue( - 'Team memory is enabled, but /tmp/.qwen/team-memory is git-ignored', - ); - - await config.refreshHierarchicalMemory(); - // A second refresh must not re-emit the warning (latched once per process). - await config.refreshHierarchicalMemory(); - - expect(getTeamMemoryShareabilityWarning).toHaveBeenCalledTimes(1); - expect(config.getWarnings()).toContainEqual( - expect.stringContaining('is git-ignored'), - ); - }); - it('refreshHierarchicalMemory should include appended auto-memory in the context warning estimate', async () => { const config = new Config({ ...baseParams, @@ -6395,7 +5993,6 @@ describe('Model Switching and Config Updates', () => { ['enableCacheControl']: false, ['forceGlobalCacheScope']: false, ['toolResultContentFormat']: 'string', - ['modalities']: { image: true }, }; vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ @@ -6407,7 +6004,6 @@ describe('Model Switching and Config Updates', () => { enableCacheControl: { kind: 'settings' }, forceGlobalCacheScope: { kind: 'settings' }, toolResultContentFormat: { kind: 'settings' }, - modalities: { kind: 'computed', detail: 'auto' }, }, }); @@ -6429,10 +6025,6 @@ describe('Model Switching and Config Updates', () => { expect(updatedConfig['enableCacheControl']).toBe(false); expect(updatedConfig['forceGlobalCacheScope']).toBe(false); expect(updatedConfig['toolResultContentFormat']).toBe('string'); - // Modalities are model-derived; a hot switch must refresh them so the - // vision-bridge gate reflects the new model (it reads getEffectiveInputModalities()). - expect(updatedConfig['modalities']).toEqual({ image: true }); - expect(config.getEffectiveInputModalities()).toEqual({ image: true }); // Verify sources are also updated const sources = config.getContentGeneratorConfigSources(); @@ -6444,7 +6036,6 @@ describe('Model Switching and Config Updates', () => { expect(sources['enableCacheControl']?.kind).toBe('settings'); expect(sources['forceGlobalCacheScope']?.kind).toBe('settings'); expect(sources['toolResultContentFormat']?.kind).toBe('settings'); - expect(sources['modalities']?.kind).toBe('computed'); }); it('should trigger full refresh when switching to non-qwen-oauth provider', async () => { @@ -6840,42 +6431,86 @@ describe('Model Switching and Config Updates', () => { }); }); - describe('MCP Stop dispatch with context usage data', () => { - it('buildContextUsage handles MCP input patterns with runtime validation', async () => { - // Test the buildContextUsage function that's used in MCP Stop dispatch - // This validates the runtime type coercion and edge cases - const { buildContextUsage } = await import('../hooks/context-usage.js'); + describe('RuntimeContext', () => { + it('should start empty', () => { + const config = new Config(baseParams); + expect(config.getRuntimeContext().size).toBe(0); + }); - // Normal case: valid numbers - expect(buildContextUsage(128000, 64000)).toEqual({ - context_usage: 0.5, - context_limit: 128000, - input_tokens: 64000, - }); + it('should set and get an entry', () => { + const config = new Config(baseParams); + expect(config.setRuntimeContextEntry('operator', 'Alice')).toBe(true); + expect(config.getRuntimeContext().get('operator')).toBe('Alice'); + }); + + it('should delete entry when value is empty', () => { + const config = new Config(baseParams); + config.setRuntimeContextEntry('operator', 'Alice'); + expect(config.setRuntimeContextEntry('operator', '')).toBe(true); + expect(config.getRuntimeContext().has('operator')).toBe(false); + }); - // Missing context_limit: returns undefined - expect(buildContextUsage(undefined, 64000)).toBeUndefined(); + it('should remove entry by key', () => { + const config = new Config(baseParams); + config.setRuntimeContextEntry('operator', 'Alice'); + config.removeRuntimeContextEntry('operator'); + expect(config.getRuntimeContext().size).toBe(0); + }); - // Missing input_tokens (defaults to 0): returns undefined - expect(buildContextUsage(128000, 0)).toBeUndefined(); + it('should reject invalid keys and return false', () => { + const config = new Config(baseParams); + expect(config.setRuntimeContextEntry('invalid key!', 'value')).toBe( + false, + ); + expect(config.setRuntimeContextEntry('', 'value')).toBe(false); + expect(config.setRuntimeContextEntry('a'.repeat(65), 'value')).toBe( + false, + ); + expect(config.getRuntimeContext().size).toBe(0); + }); - // Both missing: returns undefined - expect(buildContextUsage(undefined, 0)).toBeUndefined(); + it('should reject values exceeding 32 KiB and return false', () => { + const config = new Config(baseParams); + expect( + config.setRuntimeContextEntry('big', 'x'.repeat(32 * 1024 + 1)), + ).toBe(false); + expect(config.getRuntimeContext().size).toBe(0); + }); - // String values (MCP might send strings): Number.isFinite rejects strings - // @ts-expect-error - testing runtime validation - expect(buildContextUsage('128000', 64000)).toBeUndefined(); + it('should enforce 16 entry limit', () => { + const config = new Config(baseParams); + for (let i = 0; i < 20; i++) { + config.setRuntimeContextEntry(`key-${i}`, `value-${i}`); + } + expect(config.getRuntimeContext().size).toBe(16); + }); - // Invalid string values: returns undefined - // @ts-expect-error - testing runtime validation - expect(buildContextUsage('invalid', 64000)).toBeUndefined(); + it('should allow updating existing key even at capacity', () => { + const config = new Config(baseParams); + for (let i = 0; i < 16; i++) { + config.setRuntimeContextEntry(`key-${i}`, `value-${i}`); + } + config.setRuntimeContextEntry('key-0', 'updated'); + expect(config.getRuntimeContext().get('key-0')).toBe('updated'); + }); - // Negative values: returns undefined - expect(buildContextUsage(-128000, 64000)).toBeUndefined(); - expect(buildContextUsage(128000, -64000)).toBeUndefined(); + it('should bulk-set entries', () => { + const config = new Config(baseParams); + config.setRuntimeContextEntry('old', 'stale'); + config.setRuntimeContext({ + operator: 'Alice', + rules: 'Do not modify prod', + }); + expect(config.getRuntimeContext().size).toBe(2); + expect(config.getRuntimeContext().has('old')).toBe(false); + expect(config.getRuntimeContext().get('operator')).toBe('Alice'); + }); - // Zero context_limit: returns undefined - expect(buildContextUsage(0, 64000)).toBeUndefined(); + it('should accept values containing system-reminder tags (escaping is caller responsibility)', () => { + const config = new Config(baseParams); + const malicious = 'textinjected'; + expect(config.setRuntimeContextEntry('xss-test', malicious)).toBe(true); + expect(config.getRuntimeContext().get('xss-test')).toBe(malicious); }); }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6090cdd35df..0f0fe8204cf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1646,6 +1646,7 @@ export class Config { private mcpReconcilePromise: Promise | undefined; private sessionSubagents: SubagentConfig[]; private userMemory: string; + private runtimeContextEntries: Map = new Map(); private sdkMode: boolean; private geminiMdFileCount: number; private conditionalRulesRegistry: ConditionalRulesRegistry | undefined; @@ -4747,6 +4748,66 @@ export class Config { this.userMemory = newUserMemory; } + private static readonly RUNTIME_CONTEXT_KEY_RE = /^[a-zA-Z0-9_-]{1,64}$/; + private static readonly RUNTIME_CONTEXT_MAX_VALUE_BYTES = 32 * 1024; + private static readonly RUNTIME_CONTEXT_MAX_ENTRIES = 16; + + private getOwnRuntimeContextEntries(): Map { + if ( + !Object.prototype.hasOwnProperty.call(this, 'runtimeContextEntries') + ) { + ( + this as unknown as { runtimeContextEntries: Map } + ).runtimeContextEntries = new Map(); + } + return this.runtimeContextEntries; + } + + getRuntimeContext(): ReadonlyMap { + return this.getOwnRuntimeContextEntries(); + } + + setRuntimeContextEntry(key: string, value: string): boolean { + if (!Config.RUNTIME_CONTEXT_KEY_RE.test(key)) { + return false; + } + const entries = this.getOwnRuntimeContextEntries(); + if (!value) { + entries.delete(key); + return true; + } + if ( + Buffer.byteLength(value, 'utf8') > Config.RUNTIME_CONTEXT_MAX_VALUE_BYTES + ) { + return false; + } + if (!entries.has(key) && entries.size >= Config.RUNTIME_CONTEXT_MAX_ENTRIES) { + return false; + } + entries.set(key, value); + return true; + } + + removeRuntimeContextEntry(key: string): void { + this.getOwnRuntimeContextEntries().delete(key); + } + + setRuntimeContext(entries: Record): void { + const store = this.getOwnRuntimeContextEntries(); + store.clear(); + for (const [key, value] of Object.entries(entries)) { + if ( + value && + Config.RUNTIME_CONTEXT_KEY_RE.test(key) && + Buffer.byteLength(value, 'utf8') <= + Config.RUNTIME_CONTEXT_MAX_VALUE_BYTES && + store.size < Config.RUNTIME_CONTEXT_MAX_ENTRIES + ) { + store.set(key, value); + } + } + } + getGeminiMdFileCount(): number { return this.geminiMdFileCount; } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e1be9872490..6212ade0077 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -524,6 +524,7 @@ describe('Gemini Client (client.ts)', () => { getVertexAI: vi.fn().mockReturnValue(false), getUserAgent: vi.fn().mockReturnValue('test-agent'), getUserMemory: vi.fn().mockReturnValue(''), + getRuntimeContext: vi.fn().mockReturnValue(new Map()), getSystemPrompt: vi.fn().mockReturnValue(undefined), getAppendSystemPrompt: vi.fn().mockReturnValue(undefined), getFullContext: vi.fn().mockReturnValue(false), @@ -5392,6 +5393,48 @@ hello ); }); + it('should inject runtime context entries as system-reminders with escaping', async () => { + client['lastInjectedDate'] = 'already-set'; + const runtimeMap = new Map([ + ['operator', '当前操作者: Alice'], + ['xss', 'textinjected'], + ]); + vi.mocked(mockConfig.getRuntimeContext).mockReturnValue(runtimeMap); + + const mockStream = (async function* () { + yield { type: 'content', value: 'Hello' }; + })(); + mockTurnRunFn.mockReturnValue(mockStream); + + const mockChat: Partial = { + addHistory: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + }; + client['chat'] = mockChat as GeminiChat; + + const stream = client.sendMessageStream( + [{ text: 'test prompt' }], + new AbortController().signal, + 'prompt-id-runtime-ctx', + ); + for await (const _ of stream) { + // consume stream + } + + const callArgs = mockTurnRunFn.mock.calls[0][1] as string[]; + const operatorReminder = callArgs.find((p) => + p.includes('当前操作者: Alice'), + ); + expect(operatorReminder).toBeDefined(); + expect(operatorReminder).toMatch( + /^\n\[operator\] .*当前操作者: Alice.*\n<\/system-reminder>$/s, + ); + + const escapedReminder = callArgs.find((p) => p.includes('injected')); + expect(escapedReminder).toBeDefined(); + expect(escapedReminder).not.toContain('injected'); + }); + describe('autoSkill: scheduleSkillReview via runManagedAutoMemoryBackgroundTasks', () => { let mockStreamFn: () => AsyncGenerator<{ type: string; value: string }>; let mockChat: Partial; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index d050d988156..fcd866c9945 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -2313,6 +2313,14 @@ export class GeminiClient { systemReminders.unshift(userQueryMemory.prompt); } + const runtimeCtx = this.config.getRuntimeContext(); + for (const [key, value] of runtimeCtx) { + const safe = escapeSystemReminderTags(value); + systemReminders.push( + `\n[${key}] ${safe}\n`, + ); + } + requestToSend = [...systemReminders, ...requestToSend]; } diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 986d83ab2be..8101e780152 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -2792,6 +2792,38 @@ export class DaemonClient { ); } + async setSessionRuntimeContext( + sessionId: string, + entries: Record, + clientId?: string, + ): Promise<{ + sessionId: string; + keys: string[]; + rejected: Array<{ key: string; reason: string }>; + }> { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/runtime-context`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }, clientId), + body: JSON.stringify({ entries }), + }, + async (res) => { + if (!res.ok) { + throw await this.failOnError( + res, + 'POST /session/:id/runtime-context', + ); + } + return (await res.json()) as { + sessionId: string; + keys: string[]; + rejected: Array<{ key: string; reason: string }>; + }; + }, + ); + } + /** * Send a prompt to the agent. Supports both blocking (legacy 200) * and non-blocking (202 + SSE `turn_complete`) daemon responses. diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index c84ceeff912..af87811bd0a 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -449,6 +449,20 @@ export class DaemonSessionClient { ); } + async setRuntimeContext( + entries: Record, + ): Promise<{ + sessionId: string; + keys: string[]; + rejected: Array<{ key: string; reason: string }>; + }> { + return await this.client.setSessionRuntimeContext( + this.sessionId, + entries, + this.clientId, + ); + } + async getRewindSnapshots(): Promise<{ snapshots: DaemonRewindSnapshotInfo[]; }> {