fix: keep tool authorization valid throughout long requests

Remove the fixed gateway capability lifetime so active requests retain knowledge, notes, browser, configuration, and custom MCP access. Preserve token validation and request cancellation or cleanup revocation, and replace the misleading knowledge expiry error with a tool authorization error.

Remove native-client clock overrides and cover long-running scoped and HTTP MCP calls. Validation: 34 focused tests passed; changed-file lint passed. Repository typecheck and lint remain blocked by unrelated manual-compaction and sidebar probe files.

Release note: Long-running Agent requests no longer lose tool access after ten minutes, including when the knowledge-base switch is off. Request cancellation and completion still revoke access.
This commit is contained in:
mesalogo
2026-09-28 21:36:41 +08:00
parent 393a07ccdf
commit 1603871f7c
6 changed files with 35 additions and 43 deletions
@@ -474,6 +474,13 @@ The built-in MCP gateway returns invalid scoped-tool arguments as an
the model to correct its arguments without an MCP internal-error response. the model to correct its arguments without an MCP internal-error response.
Validation failures do not execute the tool. Validation failures do not execute the tool.
Note tools can be authorized with the knowledge-base switch off. The shared
desktop gateway binds authorization to each request, without a fixed elapsed-time
expiry, so long-running requests retain tool access. Request completion,
cancellation, failure cleanup, or gateway disposal revokes the token. Invalid or
revoked tokens report unavailable tool authorization rather than a knowledge-base
expiry error.
These note tools run in the desktop gateway. Remote gbagent ACP sessions These note tools run in the desktop gateway. Remote gbagent ACP sessions
currently inject only the image MCP server, so this change requires no currently inject only the image MCP server, so this change requires no
daemon implementation update. daemon implementation update.
@@ -151,7 +151,7 @@ describe('Obsidian gateway', () => {
gateway.revokeObsidianCapabilities() gateway.revokeObsidianCapabilities()
gateway.revokeObsidianCapabilities() gateway.revokeObsidianCapabilities()
for (const revoked of [token, writeToken, mixedToken]) { for (const revoked of [token, writeToken, mixedToken]) {
expect(() => gateway.getAvailableToolNames(revoked)).toThrow('unavailable or expired') expect(() => gateway.getAvailableToolNames(revoked)).toThrow('Tool authorization is unavailable')
} }
expect(gateway.getAvailableToolNames(knowledgeToken)).toEqual(['knowledge_list', 'knowledge_search']) expect(gateway.getAvailableToolNames(knowledgeToken)).toEqual(['knowledge_list', 'knowledge_search'])
const replacement = gateway.grant('replacement', [], signal, 'none', undefined, undefined, undefined, undefined, const replacement = gateway.grant('replacement', [], signal, 'none', undefined, undefined, undefined, undefined,
+23 -14
View File
@@ -199,6 +199,7 @@ const temporaryDirectories: string[] = []
const httpServers: Server[] = [] const httpServers: Server[] = []
afterEach(async () => { afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose())) await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose()))
for (const database of databases.splice(0)) { for (const database of databases.splice(0)) {
database.close() database.close()
@@ -663,13 +664,10 @@ describe('KnowledgeMcpGateway', () => {
).rejects.toThrow() ).rejects.toThrow()
}) })
it('creates no capability for empty scope and rejects revoked, aborted, and expired capabilities', async () => { it('keeps long-running request capabilities valid until revoked or aborted', async () => {
const { service } = createService() const { service } = createService()
let now = 1_000 const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
const gateway = new KnowledgeMcpGateway(service, { const gateway = new KnowledgeMcpGateway(service)
capabilityTtlMs: 10,
now: () => now
})
gateways.push(gateway) gateways.push(gateway)
expect( expect(
gateway.grant('empty', [], new AbortController().signal) gateway.grant('empty', [], new AbortController().signal)
@@ -680,10 +678,12 @@ describe('KnowledgeMcpGateway', () => {
[firstLibraryId], [firstLibraryId],
new AbortController().signal new AbortController().signal
)! )!
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
await expect(gateway.search(revoked, { query: 'x' })).resolves.toHaveLength(1)
gateway.revoke(revoked) gateway.revoke(revoked)
await expect( await expect(
gateway.search(revoked, { query: 'x' }) gateway.search(revoked, { query: 'x' })
).rejects.toThrow('unavailable or expired') ).rejects.toThrow('Tool authorization is unavailable')
const abortController = new AbortController() const abortController = new AbortController()
const aborted = gateway.grant( const aborted = gateway.grant(
@@ -694,21 +694,23 @@ describe('KnowledgeMcpGateway', () => {
abortController.abort() abortController.abort()
await expect( await expect(
gateway.search(aborted, { query: 'x' }) gateway.search(aborted, { query: 'x' })
).rejects.toThrow('unavailable or expired') ).rejects.toThrow('Tool authorization is unavailable')
const expired = gateway.grant( const nextRequest = gateway.grant(
'expired', 'next-request',
[firstLibraryId], [firstLibraryId],
new AbortController().signal new AbortController().signal
)! )!
now += 11 expect(nextRequest).not.toBe(revoked)
await expect( await expect(
gateway.search(expired, { query: 'x' }) gateway.search(nextRequest, { query: 'x' })
).rejects.toThrow('unavailable or expired') ).resolves.toHaveLength(1)
clock.mockRestore()
}) })
it('grants bounded global Magic Notes search without a knowledge scope', () => { it('keeps Magic Notes available during long requests without a knowledge scope', () => {
const { service } = createService() const { service } = createService()
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
const searchMagicNotes = vi.fn(() => [ const searchMagicNotes = vi.fn(() => [
{ {
noteId: '00000000-0000-4000-8000-000000000701', noteId: '00000000-0000-4000-8000-000000000701',
@@ -754,6 +756,7 @@ describe('KnowledgeMcpGateway', () => {
'read' 'read'
)! )!
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
expect(gateway.getAvailableToolNames(token)).toEqual([ expect(gateway.getAvailableToolNames(token)).toEqual([
'note_list', 'note_list',
'note_get', 'note_get',
@@ -777,6 +780,9 @@ describe('KnowledgeMcpGateway', () => {
noteIds: ['not-allowed'] noteIds: ['not-allowed']
}) })
).toThrow() ).toThrow()
gateway.revoke(token)
expect(() => gateway.getAvailableToolNames(token)).toThrow('Tool authorization is unavailable')
clock.mockRestore()
}) })
it('keeps Ask read-only and supports revision-safe Magic Notes CRUD in Execute', async () => { it('keeps Ask read-only and supports revision-safe Magic Notes CRUD in Execute', async () => {
@@ -980,6 +986,7 @@ describe('KnowledgeMcpGateway', () => {
}) })
it('proxies custom MCP through a request-scoped loopback token without exposing the upstream credential', async () => { it('proxies custom MCP through a request-scoped loopback token without exposing the upstream credential', async () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
const upstreamAuthorizations: Array<string | undefined> = [] const upstreamAuthorizations: Array<string | undefined> = []
const upstream = createServer(async (request, response) => { const upstream = createServer(async (request, response) => {
upstreamAuthorizations.push(request.headers.authorization) upstreamAuthorizations.push(request.headers.authorization)
@@ -1081,6 +1088,7 @@ describe('KnowledgeMcpGateway', () => {
expect(JSON.stringify(listed)).not.toContain( expect(JSON.stringify(listed)).not.toContain(
`127.0.0.1:${address.port}` `127.0.0.1:${address.port}`
) )
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
const result = await client.callTool({ const result = await client.callTool({
name: listed.tools[0]!.name, name: listed.tools[0]!.name,
arguments: { value: 'hello' } arguments: { value: 'hello' }
@@ -1094,6 +1102,7 @@ describe('KnowledgeMcpGateway', () => {
} finally { } finally {
gateway.revoke(token) gateway.revoke(token)
await client.close() await client.close()
clock.mockRestore()
} }
}) })
+3 -25
View File
@@ -103,8 +103,6 @@ const MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY = 8
const CUSTOM_MCP_TIMEOUT_MS = 30_000 const CUSTOM_MCP_TIMEOUT_MS = 30_000
const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000 const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000 const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
const MAX_CAPABILITY_TTL_MS = 15 * 60_000
const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator() const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator()
export { export {
@@ -215,7 +213,6 @@ type Capability = {
browserTabId?: BrowserTabId browserTabId?: BrowserTabId
browserUsageLease?: BrowserTabUsageLease browserUsageLease?: BrowserTabUsageLease
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' } obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
expiresAt: number
signal: AbortSignal signal: AbortSignal
brokerController: AbortController brokerController: AbortController
customMcpServers: readonly ResolvedMcpServer[] customMcpServers: readonly ResolvedMcpServer[]
@@ -256,9 +253,7 @@ type DownstreamMcpSession = {
export type KnowledgeMcpGatewayOptions = { export type KnowledgeMcpGatewayOptions = {
storyGraphService?: StoryGraphService storyGraphService?: StoryGraphService
capabilityTtlMs?: number
maximumBodyBytes?: number maximumBodyBytes?: number
now?: () => number
magicNotesDatabase?: MagicNotesDatabase magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService configService?: GoodBuddyConfigService
obsidianService?: ObsidianService obsidianService?: ObsidianService
@@ -387,8 +382,6 @@ export class KnowledgeMcpGateway {
private readonly downstreamMcpCleanups = new Set<Promise<void>>() private readonly downstreamMcpCleanups = new Set<Promise<void>>()
private readonly customMcpCleanups = new Set<Promise<void>>() private readonly customMcpCleanups = new Set<Promise<void>>()
private readonly activeObsidianCalls = new Set<Promise<CallToolResult>>() private readonly activeObsidianCalls = new Set<Promise<CallToolResult>>()
private readonly now: () => number
private readonly capabilityTtlMs: number
private readonly maximumBodyBytes: number private readonly maximumBodyBytes: number
private readonly magicNotesDatabase?: MagicNotesDatabase private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService private readonly configService?: GoodBuddyConfigService
@@ -410,18 +403,8 @@ export class KnowledgeMcpGateway {
private readonly knowledgeService: KnowledgeService, private readonly knowledgeService: KnowledgeService,
options: KnowledgeMcpGatewayOptions = {} options: KnowledgeMcpGatewayOptions = {}
) { ) {
const ttl = options.capabilityTtlMs ?? DEFAULT_CAPABILITY_TTL_MS
if (
!Number.isSafeInteger(ttl) ||
ttl < 1 ||
ttl > MAX_CAPABILITY_TTL_MS
) {
throw new RangeError('Knowledge capability TTL is invalid')
}
this.capabilityTtlMs = ttl
this.maximumBodyBytes = this.maximumBodyBytes =
options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES
this.now = options.now ?? Date.now
this.storyGraphService = options.storyGraphService this.storyGraphService = options.storyGraphService
this.magicNotesDatabase = options.magicNotesDatabase this.magicNotesDatabase = options.magicNotesDatabase
this.configService = options.configService this.configService = options.configService
@@ -615,7 +598,6 @@ export class KnowledgeMcpGateway {
private storeCapability( private storeCapability(
value: Omit< value: Omit<
Capability, Capability,
| 'expiresAt'
| 'references' | 'references'
| 'removeAbortListener' | 'removeAbortListener'
| 'brokerController' | 'brokerController'
@@ -628,10 +610,10 @@ export class KnowledgeMcpGateway {
const abort = (): void => { const abort = (): void => {
this.revoke(token) this.revoke(token)
} }
// Capabilities live with the request; elapsed time must not interrupt long runs.
value.signal.addEventListener('abort', abort, { once: true }) value.signal.addEventListener('abort', abort, { once: true })
this.capabilities.set(token, { this.capabilities.set(token, {
...value, ...value,
expiresAt: this.now() + this.capabilityTtlMs,
brokerController, brokerController,
references: new Map(), references: new Map(),
removeAbortListener: () => { removeAbortListener: () => {
@@ -713,13 +695,9 @@ export class KnowledgeMcpGateway {
private getCapability(token: string): Capability { private getCapability(token: string): Capability {
const capability = this.capabilities.get(token) const capability = this.capabilities.get(token)
if ( if (!capability || capability.signal.aborted) {
!capability ||
capability.signal.aborted ||
capability.expiresAt <= this.now()
) {
this.revoke(token) this.revoke(token)
throw new Error('Knowledge capability is unavailable or expired') throw new Error('Tool authorization is unavailable: the request has ended or the token is invalid')
} }
return capability return capability
} }
-2
View File
@@ -1446,8 +1446,6 @@ if (hasSingleInstanceLock) {
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'), (await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraph(name, input, projectId, signal) read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraph(name, input, projectId, signal)
}, },
// This dedicated gateway lives until native-client disposal, not a chat request timeout.
now: () => 0,
magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService, magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService,
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider
}) })
+1 -1
View File
@@ -9041,7 +9041,7 @@ describe('registerIpcHandlers agent terminal state', () => {
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'), rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }), bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath, npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
createGateway: () => new KnowledgeMcpGateway({} as never, { now: () => 0 }), createGateway: () => new KnowledgeMcpGateway({} as never),
openExternal: async (url) => { opened.push(url) } openExternal: async (url) => { opened.push(url) }
}) })
harness = createHarness({}, undefined, 'always', undefined, false, undefined, undefined, undefined, harness = createHarness({}, undefined, 'always', undefined, false, undefined, undefined, undefined,