@fg-lab/amadeus-qq 0.0.0-stage → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/README.zh-CN.md +51 -0
  4. package/REFERENCE.md +49 -0
  5. package/assets/market-icon.png +0 -0
  6. package/cordis.patch.yml +6 -0
  7. package/docs/COMPATIBILITY.md +17 -0
  8. package/docs/DATA_MIGRATION.md +26 -0
  9. package/docs/INSTALL_V1_2.md +22 -0
  10. package/docs/PUBLISHING.md +28 -0
  11. package/docs/RELEASE_CHECKLIST.md +23 -0
  12. package/docs/RELEASE_NOTES.md +38 -0
  13. package/docs/UPGRADE_POLICY.md +26 -0
  14. package/docs/V1_SCOPE.md +33 -0
  15. package/docs/screenshots/account-browser.png +0 -0
  16. package/docs/screenshots/account-dark-browser.png +0 -0
  17. package/docs/screenshots/global-desktop.png +0 -0
  18. package/docs/screenshots/overview-desktop.png +0 -0
  19. package/docs/screenshots/product-release-20261031.png +0 -0
  20. package/docs/screenshots/v1.2-overview.png +0 -0
  21. package/docs/v1.1/PRESET_MATRIX.md +28 -0
  22. package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
  23. package/docs/v1.1/TEST_REPORT.md +20 -0
  24. package/docs/v1.1/UPGRADE.md +10 -0
  25. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  26. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  27. package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
  28. package/lib/client.js +380 -0
  29. package/package.json +92 -4
  30. package/preset.patch.yml +166 -0
  31. package/screenshots.json +6 -0
  32. package/scripts/build-client.mjs +52 -0
  33. package/scripts/registry-guard.mjs +12 -0
  34. package/scripts/release-audit.mjs +59 -0
  35. package/scripts/symlink-security.mjs +8 -0
  36. package/src/account-profile.js +223 -0
  37. package/src/admin-http.js +361 -0
  38. package/src/audio.js +428 -0
  39. package/src/capabilities.js +785 -0
  40. package/src/client/factory.template.js +82 -0
  41. package/src/client/logic.js +290 -0
  42. package/src/config.js +50 -0
  43. package/src/console/console.css +28 -0
  44. package/src/console/console.js +541 -0
  45. package/src/console/index.html +67 -0
  46. package/src/core.js +909 -0
  47. package/src/files.js +230 -0
  48. package/src/index.js +994 -0
  49. package/src/lease.js +42 -0
  50. package/src/media.js +309 -0
  51. package/src/napcat-action-names.js +186 -0
  52. package/src/native-asr.js +112 -0
  53. package/src/onebot.js +378 -0
  54. package/src/outbound-media.js +309 -0
  55. package/src/owner-workspace.js +394 -0
  56. package/src/permissions.js +270 -0
  57. package/src/preset-guard.mjs +593 -0
  58. package/src/qzone-provider.js +628 -0
  59. package/src/session-provenance.js +74 -0
  60. package/src/setup.js +255 -0
  61. package/src/storage.js +580 -0
  62. package/src/web-console.js +9 -0
@@ -0,0 +1,593 @@
1
+ // QQ platform operation contract and Agent-preset guard.
2
+ //
3
+ // This module is mounted by the package's preset.patch.yml, never by the QQ
4
+ // connector row. A persisted QQ Session therefore keeps the boundary while the
5
+ // connector is disabled, restarting, or temporarily uninstalled. The execution
6
+ // guard is the hard boundary; the visible-schema restriction is retried as the
7
+ // connector registers its tools.
8
+ //
9
+ // DSH's tools.restrict() synchronously emits tools/change on apply AND dispose.
10
+ // Every visibility mutation below is therefore idempotent, marks pending state
11
+ // before mutating, and is re-entrancy guarded; otherwise the echo can recurse
12
+ // during Host activation and stall startup.
13
+
14
+ import {
15
+ OWNER_WORKSPACE_CAPABILITY,
16
+ OWNER_WORKSPACE_TOOL,
17
+ setProviderStatus,
18
+ } from './owner-workspace.js'
19
+
20
+ export const name = 'amadeus-qq-preset-guard'
21
+ export const inject = ['tools']
22
+
23
+ /** Preset tool ceilings; current Scene grants are checked again at execution. */
24
+ export const QQ_PERMISSION_LEVELS = Object.freeze([
25
+ Object.freeze({ level: 0, id: 'observe', description: 'Read inbox and connection state; no external writes.' }),
26
+ Object.freeze({ level: 1, id: 'safe-chat', description: 'Continuous conversation and authorized QQ sends.' }),
27
+ Object.freeze({ level: 2, id: 'enhanced', description: 'Authorized media reads and sends.' }),
28
+ Object.freeze({ level: 3, id: 'autonomous-social', description: 'Authorized QZone operations; account grants still apply.' }),
29
+ Object.freeze({ level: 4, id: 'owner-agent', description: 'Verified Owner private Scene tools; general Shell remains unavailable.' }),
30
+ ])
31
+
32
+ export const MAX_QQ_PERMISSION_LEVEL = QQ_PERMISSION_LEVELS.length - 1
33
+ export const DEFAULT_QQ_PERMISSION_LEVEL = 1
34
+
35
+ /** Official DSH standard tools exposed only by the level-4 Owner preset. */
36
+ export const OWNER_NATIVE_TOOLS = Object.freeze([
37
+ 'pwsh', 'read', 'read_image', 'write', 'edit', 'glob', 'grep',
38
+ 'job_output', 'job_list', 'job_kill', 'web_search', 'web_fetch', 'present',
39
+ ])
40
+
41
+ /**
42
+ * The only Memory tools a non-Owner QQ preset may call. All three are
43
+ * read-only and are the Memory tools whose result set is filtered by the
44
+ * trusted-Scene ACL on the Memory server; `scenes` on search/list is only a
45
+ * narrowing selector over what that server already allows. `memory_get` is in
46
+ * the set because it applies the same server-side visibility check as list.
47
+ * Writes, deletes, scope controls, library-wide identity/preference reads,
48
+ * runtime provisioning and unknown/future `memory_*` tools stay denied.
49
+ */
50
+ export const MEMORY_READ_TOOLS = Object.freeze(['memory_search', 'memory_list', 'memory_get'])
51
+ const MEMORY_READ_TOOL_SET = new Set(MEMORY_READ_TOOLS)
52
+ // Session-bound Life views are safe to expose by name; the connector applies
53
+ // the explicit Scene capability at execution time.
54
+ export const LIFE_READ_TOOLS = Object.freeze(['life_shared_read', 'life_health'])
55
+ const LIFE_READ_TOOL_SET = new Set(LIFE_READ_TOOLS)
56
+
57
+ /**
58
+ * Cordis service contract for the trusted-Scene read capability.
59
+ *
60
+ * QQ deliberately does NOT infer this from tool names, plugin versions, or
61
+ * config-shaped guesses: an upstream/old Mneme also registers memory_* tools
62
+ * but has no per-Scene ACL, so name-only gating would leak other Scenes. The
63
+ * Amadeus Memory plugin must provide this service and return the declaration
64
+ * below only while its trusted-Scene ACL is actually active. Missing, old,
65
+ * malformed, incomplete, or withdrawn declarations fail closed.
66
+ *
67
+ * Memory-side minimal implementation (the blocking cross-repo change):
68
+ *
69
+ * const qq = ctx.reflect?.get?.('qq')
70
+ * ctx.provide('amadeus.memory', {
71
+ * sceneAclRead: () => cfg.sceneIsolationEnabled === true &&
72
+ * cfg.sceneStrictQQ === true &&
73
+ * typeof qq?.resolveTrustedSessionScene === 'function'
74
+ * ? {
75
+ * contract: 'amadeus-memory/trusted-scene-acl-read',
76
+ * version: 1,
77
+ * trustedSceneAcl: true,
78
+ * strictQQ: true,
79
+ * tools: ['memory_search', 'memory_list', 'memory_get']
80
+ * }
81
+ * : null
82
+ * })
83
+ *
84
+ * The declaration is synchronous and is re-read at execution time, so turning
85
+ * the Memory Scene policy off after startup also closes the QQ side.
86
+ */
87
+ export const MEMORY_SCENE_ACL_SERVICE = 'amadeus.memory'
88
+ export const MEMORY_SCENE_ACL_CONTRACT = 'amadeus-memory/trusted-scene-acl-read'
89
+ export const MEMORY_SCENE_ACL_VERSION = 1
90
+
91
+ function readHostService(ctx, name) {
92
+ try {
93
+ if (typeof ctx?.reflect?.get === 'function') return ctx.reflect.get(name)
94
+ } catch {
95
+ // Fall through to ctx.get only for hosts without a usable reflect layer.
96
+ }
97
+ try {
98
+ if (typeof ctx?.get === 'function') return ctx.get(name)
99
+ } catch {
100
+ // Unavailable is indistinguishable from absent: fail closed.
101
+ }
102
+ return undefined
103
+ }
104
+
105
+ /**
106
+ * True only when Amadeus Memory explicitly declares a trusted-Scene ACL read
107
+ * capability covering all three bounded readers.
108
+ * @param {unknown} ctx - preset or connector scope with reflect/get access.
109
+ * @returns {boolean}
110
+ */
111
+ export function memorySceneAclReadGranted(ctx) {
112
+ const service = readHostService(ctx, MEMORY_SCENE_ACL_SERVICE)
113
+ if (!service || (typeof service !== 'object' && typeof service !== 'function')) return false
114
+ let declared
115
+ try {
116
+ declared = typeof service.sceneAclRead === 'function'
117
+ ? service.sceneAclRead()
118
+ : service.sceneAclRead
119
+ } catch {
120
+ return false
121
+ }
122
+ try {
123
+ if (!declared || typeof declared !== 'object' || Array.isArray(declared)) return false
124
+ if (declared.contract !== MEMORY_SCENE_ACL_CONTRACT) return false
125
+ if (declared.version !== MEMORY_SCENE_ACL_VERSION) return false
126
+ if (declared.trustedSceneAcl !== true || declared.strictQQ !== true) return false
127
+ if (!Array.isArray(declared.tools)) return false
128
+ const declaredTools = new Set(declared.tools.filter(name => typeof name === 'string'))
129
+ return MEMORY_READ_TOOLS.every(name => declaredTools.has(name))
130
+ } catch {
131
+ // A declaration that throws while being inspected is not a declaration.
132
+ return false
133
+ }
134
+ }
135
+
136
+ /** @param {unknown} name @returns {boolean} whether name is a bounded Memory reader. */
137
+ export function isMemoryReadTool(name) {
138
+ return typeof name === 'string' && MEMORY_READ_TOOL_SET.has(name)
139
+ }
140
+
141
+ /**
142
+ * Minimum permission level per QQ platform tool. This is the extension point:
143
+ * a future capability becomes visible only after it is declared here, and it is
144
+ * denied below its declared level at execution time.
145
+ */
146
+ export const QQ_TOOL_CONTRACT = Object.freeze({
147
+ qq_wait_for_messages: Object.freeze({ level: 0, capability: 'wait' }),
148
+ qq_get_unread_messages: Object.freeze({ level: 0, capability: 'read' }),
149
+ qq_mark_read: Object.freeze({ level: 0, capability: 'read' }),
150
+ qq_connection_status: Object.freeze({ level: 0, capability: 'status' }),
151
+ qq_capability_search: Object.freeze({ level: 0, capability: 'discover' }),
152
+ qq_capability_describe: Object.freeze({ level: 0, capability: 'discover' }),
153
+ qq_send_message: Object.freeze({ level: 1, capability: 'send' }),
154
+ qq_capability_execute: Object.freeze({ level: 1, capability: 'execute' }),
155
+ qq_send_media: Object.freeze({ level: 2, capability: 'media.send' }),
156
+ qq_upload_file: Object.freeze({ level: 2, capability: 'media.send' }),
157
+ qq_qzone: Object.freeze({ level: 3, capability: 'qzone' }),
158
+ qq_account_profile: Object.freeze({ level: 4, capability: 'profile' }),
159
+ qq_read_message_image: Object.freeze({ level: 2, capability: 'media.read' }),
160
+ qq_transcribe_message_record: Object.freeze({ level: 2, capability: 'media.read' }),
161
+ qq_transcribe_message_record_native: Object.freeze({ level: 1, capability: 'read' }),
162
+ qq_read_message_file: Object.freeze({ level: 2, capability: 'media.read' }),
163
+ qq_read_message_video: Object.freeze({ level: 2, capability: 'media.read' }),
164
+ })
165
+
166
+ export const QQ_TOOL_NAMES = Object.freeze(Object.keys(QQ_TOOL_CONTRACT))
167
+
168
+ /** @param {number} level - inclusive maximum permission level. @returns {string[]} allowed tool names. */
169
+ export function toolsForLevel(level) {
170
+ return QQ_TOOL_NAMES.filter(name => QQ_TOOL_CONTRACT[name].level <= level)
171
+ }
172
+
173
+ /**
174
+ * Validate one preset row's config and resolve its allowed tool set.
175
+ * Invalid levels throw so the preset fails activation instead of running with
176
+ * an accidental default.
177
+ * @param {unknown} config - preset row config.
178
+ * @returns {{level: number, levelId: string, tools: readonly string[], allowSearch: boolean}} policy.
179
+ */
180
+ export function resolveQQPermissionPolicy(config = {}) {
181
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
182
+ throw new TypeError('QQ preset config must be an object')
183
+ }
184
+ const level = config.level === undefined ? DEFAULT_QQ_PERMISSION_LEVEL : config.level
185
+ if (!Number.isInteger(level) || level < 0 || level > MAX_QQ_PERMISSION_LEVEL) {
186
+ throw new TypeError(`QQ preset level must be an integer from 0 to ${MAX_QQ_PERMISSION_LEVEL}`)
187
+ }
188
+ const allowSearch = config.allowSearch === undefined ? false : config.allowSearch
189
+ if (typeof allowSearch !== 'boolean') throw new TypeError('QQ preset allowSearch must be a boolean')
190
+ const allowLifeRead = config.allowLifeRead === undefined ? false : config.allowLifeRead
191
+ if (typeof allowLifeRead !== 'boolean') throw new TypeError('QQ preset allowLifeRead must be a boolean')
192
+ const entry = QQ_PERMISSION_LEVELS[level]
193
+ // Memory readers are intentionally not part of the static preset policy.
194
+ // They are opened only after the live Memory service declares the trusted
195
+ // Scene ACL contract (see MEMORY_SCENE_ACL_SERVICE above); the Owner preset
196
+ // reaches them through its existing generic, Owner-authorized host-tool path.
197
+ const tools = allowSearch
198
+ ? [...toolsForLevel(level), 'web_search']
199
+ : toolsForLevel(level)
200
+ return Object.freeze({ level, levelId: entry.id, tools: Object.freeze(tools), allowSearch, allowLifeRead })
201
+ }
202
+
203
+ /**
204
+ * Mount the QQ boundary in the preset scope. The guard denies by tool name
205
+ * regardless of connector state; visible-schema restrictions are applied to
206
+ * already-known tools and retried on tools/change until the QQ tool set is
207
+ * complete.
208
+ * @param {{tools: {guard: Function, restrict: Function, schemas?: Function, presentAs?: Function}, on?: Function, effect?: Function}} ctx - preset scope.
209
+ * @param {unknown} config - row config, currently `{ level?: 0|1|2|3|4 }`.
210
+ */
211
+ export function apply(ctx, config = {}) {
212
+ const policy = resolveQQPermissionPolicy(config)
213
+ const allowed = new Set(policy.tools)
214
+ // Tools that may legitimately be absent without blocking the visible allow
215
+ // mask: connector roots that depend on live configuration, the Owner
216
+ // workspace, and the optional Memory plugin. Memory readers enter the visible
217
+ // policy only when the trusted-Scene contract is present.
218
+ const OPTIONAL_TOOLS = new Set([
219
+ 'qq_send_media', 'qq_upload_file', 'qq_qzone', 'qq_account_profile', OWNER_WORKSPACE_TOOL,
220
+ ...MEMORY_READ_TOOLS, ...LIFE_READ_TOOLS,
221
+ ])
222
+ const OWNER_NATIVE = new Set(OWNER_NATIVE_TOOLS)
223
+ // Host plugins can register tools after this preset starts. Keep the QQ
224
+ // namespace on its explicit contract; all other registered native tools are
225
+ // usable only by a currently authorized Owner private Session.
226
+ const ownerHostTool = name => typeof name === 'string' && name !== 'run_code' &&
227
+ !name.startsWith('qq_') && !Object.hasOwn(QQ_TOOL_CONTRACT, name) && name !== OWNER_WORKSPACE_TOOL
228
+ const QZONE_READ_OPS = new Set(['own_posts', 'friend_feeds', 'comments'])
229
+ const QZONE_WRITE_OPS = new Set(['publish_text', 'publish_media', 'like', 'unlike', 'comment', 'reply', 'forward', 'delete'])
230
+
231
+ // The Owner workspace tool itself is registered by the connector with the
232
+ // live config; this preset owns only the per-Session allowlist and guard.
233
+ // Static preset-row roots are deliberately not read here.
234
+ if (policy.level === 4) {
235
+ allowed.add(OWNER_WORKSPACE_TOOL)
236
+ for (const tool of OWNER_NATIVE_TOOLS) allowed.add(tool)
237
+ }
238
+
239
+ // PTC exposes a code-execution transport outside the capability filter.
240
+ // Force native presentation for every Agent using this preset; a conflicting
241
+ // composition fails activation instead of silently keeping run_code.
242
+ if (typeof ctx.tools.presentAs === 'function') {
243
+ try {
244
+ ctx.tools.presentAs('native')
245
+ } catch {
246
+ throw new Error('QQ preset requires native tool presentation; PTC run_code is outside the QQ contract')
247
+ }
248
+ }
249
+
250
+ const isRegistered = name => {
251
+ if (typeof ctx.tools.schemas !== 'function') return false
252
+ try {
253
+ return (ctx.tools.schemas() ?? []).some(schema => schema?.name === name)
254
+ } catch {
255
+ return false
256
+ }
257
+ }
258
+
259
+ // Memory readers are opt-in at runtime, not by preset level. The declaration
260
+ // is re-read for every visibility refresh and every execution, so an absent,
261
+ // old or withdrawn Memory service cannot leave the tools open.
262
+ //
263
+ // DSH's tools.restrict() synchronously emits tools/change (its effect notifies
264
+ // on apply and on dispose). Every mutation below therefore uses a re-entrancy
265
+ // guard plus a "mark before mutate" state so the event it causes cannot
266
+ // re-enter the same mutation. The outer refresh loop runs at most a bounded
267
+ // number of passes; a pathological host cannot turn it into a CPU loop.
268
+ let memoryReadGranted = memorySceneAclReadGranted(ctx)
269
+ const memoryReaderDenies = new Map() // reader name -> deny disposer (null while pending)
270
+ let syncingMemoryReaders = false
271
+ const discoverMemoryReaders = () => {
272
+ if (typeof ctx.tools.schemas !== 'function') return []
273
+ try {
274
+ return (ctx.tools.schemas() ?? [])
275
+ .map(schema => schema?.name)
276
+ .filter(name => typeof name === 'string' && MEMORY_READ_TOOL_SET.has(name))
277
+ } catch {
278
+ return []
279
+ }
280
+ }
281
+ /** Hide the readers while the contract is absent; restore them when it appears. */
282
+ const syncMemoryReaderVisibility = () => {
283
+ if (syncingMemoryReaders) return
284
+ syncingMemoryReaders = true
285
+ try {
286
+ if (memoryReadGranted) {
287
+ const disposers = new Set(memoryReaderDenies.values())
288
+ memoryReaderDenies.clear()
289
+ for (const dispose of disposers) {
290
+ if (typeof dispose !== 'function') continue
291
+ try { dispose() } catch { /* already disposed */ }
292
+ }
293
+ return
294
+ }
295
+ const fresh = discoverMemoryReaders().filter(name => !memoryReaderDenies.has(name))
296
+ if (fresh.length === 0) return
297
+ // Mark pending before restrict(): the synchronous tools/change event
298
+ // must see these names as already handled.
299
+ for (const name of fresh) memoryReaderDenies.set(name, null)
300
+ try {
301
+ const dispose = ctx.tools.restrict({ deny: fresh })
302
+ for (const name of fresh) memoryReaderDenies.set(name, dispose)
303
+ return
304
+ } catch {
305
+ for (const name of fresh) memoryReaderDenies.delete(name)
306
+ }
307
+ for (const name of fresh) {
308
+ memoryReaderDenies.set(name, null)
309
+ try {
310
+ memoryReaderDenies.set(name, ctx.tools.restrict({ deny: [name] }))
311
+ } catch {
312
+ memoryReaderDenies.delete(name)
313
+ }
314
+ }
315
+ } finally {
316
+ syncingMemoryReaders = false
317
+ }
318
+ }
319
+
320
+ const authorizeCapability = (exec, capability) => {
321
+ const sessionId = exec?.agent?.session?.id
322
+ if (typeof sessionId !== 'string' || !sessionId) return false
323
+ let service
324
+ try {
325
+ service = typeof ctx.get === 'function' ? ctx.get('qq') : undefined
326
+ } catch {
327
+ return false
328
+ }
329
+ if (!service || typeof service.authorizeSessionCapability !== 'function') return false
330
+ try {
331
+ return service.authorizeSessionCapability(sessionId, capability) === true
332
+ } catch {
333
+ return false
334
+ }
335
+ }
336
+
337
+ const authorizeSearch = exec => authorizeCapability(exec, 'dsh.search')
338
+
339
+ const authorizeOwner = exec => {
340
+ const sessionId = exec?.agent?.session?.id
341
+ if (typeof sessionId !== 'string' || !sessionId) return false
342
+ let service
343
+ try {
344
+ service = typeof ctx.get === 'function' ? ctx.get('qq') : undefined
345
+ } catch {
346
+ return false
347
+ }
348
+ if (!service || typeof service.resolveTrustedSessionScene !== 'function' ||
349
+ typeof service.authorizeSessionCapability !== 'function') return false
350
+ try {
351
+ if (service.resolveTrustedSessionScene(sessionId)?.admin !== true) return false
352
+ return service.authorizeSessionCapability(sessionId, 'qq.owner.tools') === true
353
+ } catch {
354
+ return false
355
+ }
356
+ }
357
+
358
+ ctx.tools.guard(exec => {
359
+ const tool = exec?.name
360
+ if (typeof tool !== 'string') return 'QQ Session cannot use non-QQ tools'
361
+ if (tool === 'web_search') {
362
+ if (policy.level === 4 && authorizeOwner(exec)) return undefined
363
+ if (!policy.allowSearch) return 'QQ Session cannot use non-QQ tools'
364
+ return authorizeSearch(exec) ? undefined : 'QQ search permission denied'
365
+ }
366
+ if (OWNER_NATIVE.has(tool)) {
367
+ if (policy.level !== 4) return 'QQ Session cannot use non-QQ tools'
368
+ return authorizeOwner(exec) ? undefined : 'QQ owner native tool permission denied'
369
+ }
370
+ if (tool === 'qq_send_media' || tool === 'qq_upload_file') {
371
+ if (policy.level < 2) return `QQ Session permission level ${policy.level} does not allow ${tool}`
372
+ if (!isRegistered(tool)) return 'QQ media send root is not configured'
373
+ return authorizeCapability(exec, 'qq.send') ? undefined : 'QQ send permission denied'
374
+ }
375
+ if (tool === 'qq_qzone') {
376
+ if (policy.level < 3) return `QQ Session permission level ${policy.level} does not allow ${tool}`
377
+ if (!isRegistered(tool)) return 'QQ QZone provider is not configured'
378
+ const op = exec?.arguments?.op
379
+ if (!QZONE_READ_OPS.has(op) && !QZONE_WRITE_OPS.has(op)) return 'QQ QZone operation is not allowed'
380
+ const write = QZONE_WRITE_OPS.has(op)
381
+ return (authorizeCapability(exec, 'qq.qzone.use') ||
382
+ authorizeCapability(exec, write ? 'qq.qzone.publish' : 'qq.qzone.read'))
383
+ ? undefined
384
+ : 'QQ QZone permission denied'
385
+ }
386
+ if (tool === OWNER_WORKSPACE_TOOL) {
387
+ if (policy.level !== 4) return `QQ Session permission level ${policy.level} does not allow ${tool}`
388
+ if (!isRegistered(tool)) return 'QQ owner workspace is not registered'
389
+ if (!authorizeCapability(exec, OWNER_WORKSPACE_CAPABILITY)) return 'QQ owner workspace permission denied'
390
+ return undefined
391
+ }
392
+ if (tool === 'qq_account_profile') {
393
+ if (policy.level !== 4) return 'QQ Session permission level ' + policy.level + ' does not allow ' + tool
394
+ if (!isRegistered(tool)) return 'QQ account profile is not registered'
395
+ const op = exec?.arguments?.op
396
+ if (op === 'read') {
397
+ return authorizeCapability(exec, 'qq.read') ? undefined : 'QQ profile permission denied'
398
+ }
399
+ if (['set_signature', 'set_nickname', 'set_online_status', 'set_avatar'].includes(op)) {
400
+ return authorizeCapability(exec, 'qq.profile.write') ? undefined : 'QQ profile permission denied'
401
+ }
402
+ return 'QQ profile operation is not allowed'
403
+ }
404
+ if (MEMORY_READ_TOOL_SET.has(tool) && policy.level !== 4) {
405
+ // Name gating is not enough: old/upstream Mneme registers the same tools
406
+ // without a trusted-Scene ACL. Only the explicit service declaration may
407
+ // open them, and it is checked live at execution time.
408
+ return memorySceneAclReadGranted(ctx)
409
+ ? undefined
410
+ : 'QQ Memory trusted Scene ACL read contract is unavailable'
411
+ }
412
+ if (LIFE_READ_TOOL_SET.has(tool)) {
413
+ return authorizeCapability(exec, 'amadeus.life.read')
414
+ ? undefined
415
+ : 'QQ Session Life read permission denied'
416
+ }
417
+ if (allowed.has(tool)) return undefined
418
+ if (Object.hasOwn(QQ_TOOL_CONTRACT, tool)) {
419
+ return `QQ Session permission level ${policy.level} does not allow ${tool}`
420
+ }
421
+ if (policy.level === 4 && ownerHostTool(tool) && isRegistered(tool)) {
422
+ return authorizeOwner(exec) ? undefined : 'QQ owner native tool permission denied'
423
+ }
424
+ if (policy.level !== 4 && tool.startsWith('memory_')) {
425
+ // The three readers were handled by the live contract branch above.
426
+ // Everything else in the Memory namespace is a write, scope/
427
+ // administrative action, or an unknown future tool; none of them is
428
+ // delegated to the Memory server for an ordinary QQ Scene.
429
+ return `QQ Session may only use Memory readers: ${MEMORY_READ_TOOLS.join(', ')}`
430
+ }
431
+ return 'QQ Session cannot use non-QQ tools'
432
+ })
433
+
434
+ let visibleRestriction
435
+ let visibleNames = ''
436
+ let applyingVisible = false
437
+ const deniedVisible = new Set()
438
+ let hidingKnown = false
439
+
440
+ /** Hide every currently known non-QQ tool, even while the connector is off. */
441
+ const hideKnownNonQQ = () => {
442
+ if (hidingKnown) return
443
+ if (typeof ctx.tools.schemas !== 'function') return
444
+ let schemas
445
+ try {
446
+ schemas = ctx.tools.schemas()
447
+ } catch {
448
+ return
449
+ }
450
+ const fresh = schemas
451
+ .map(schema => schema?.name)
452
+ .filter(name => typeof name === 'string' && !MEMORY_READ_TOOL_SET.has(name) &&
453
+ !LIFE_READ_TOOL_SET.has(name) &&
454
+ !allowed.has(name) &&
455
+ !(policy.level === 4 && ownerHostTool(name)) && name !== 'run_code' && !deniedVisible.has(name))
456
+ if (fresh.length === 0) return
457
+ hidingKnown = true
458
+ try {
459
+ // Mark before restrict(): restrict() synchronously emits tools/change.
460
+ for (const name of fresh) deniedVisible.add(name)
461
+ try {
462
+ ctx.tools.restrict({ deny: fresh })
463
+ return
464
+ } catch {
465
+ // One unknown or scope-local name must not block hiding the rest.
466
+ for (const name of fresh) deniedVisible.delete(name)
467
+ }
468
+ for (const name of fresh) {
469
+ deniedVisible.add(name)
470
+ try {
471
+ ctx.tools.restrict({ deny: [name] })
472
+ } catch {
473
+ deniedVisible.delete(name)
474
+ // Keep the execution guard as the hard boundary and retry later.
475
+ }
476
+ }
477
+ } finally {
478
+ hidingKnown = false
479
+ }
480
+ }
481
+
482
+ /** Install or refresh the allow mask as optional tools appear. */
483
+ const applyVisibleRestriction = () => {
484
+ if (applyingVisible) return
485
+ let known
486
+ try {
487
+ known = new Set((ctx.tools.schemas?.() ?? []).map(schema => schema?.name))
488
+ } catch {
489
+ return
490
+ }
491
+ const visiblePolicy = [
492
+ ...policy.tools,
493
+ ...(memoryReadGranted ? MEMORY_READ_TOOLS : []),
494
+ ...(policy.allowLifeRead ? LIFE_READ_TOOLS : []),
495
+ ...(policy.level === 4
496
+ ? [OWNER_WORKSPACE_TOOL, ...[...known].filter(ownerHostTool)]
497
+ : []),
498
+ ].filter(name => !OPTIONAL_TOOLS.has(name) || known.has(name))
499
+ if (!visiblePolicy.every(name => known.has(name))) return
500
+ const nextNames = visiblePolicy.join(',')
501
+ if (visibleRestriction && nextNames === visibleNames) return
502
+ applyingVisible = true
503
+ try {
504
+ if (visibleRestriction) {
505
+ const dispose = visibleRestriction
506
+ visibleRestriction = undefined
507
+ visibleNames = ''
508
+ try { dispose() } catch { /* already disposed */ }
509
+ }
510
+ try {
511
+ visibleRestriction = ctx.tools.restrict({ allow: visiblePolicy })
512
+ visibleNames = nextNames
513
+ } catch {
514
+ visibleRestriction = undefined
515
+ visibleNames = ''
516
+ }
517
+ } finally {
518
+ applyingVisible = false
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Re-read the Memory declaration. Cordis emits `internal/service` when a
524
+ * provided service appears, changes or is withdrawn; `tools/change` is the
525
+ * fallback for registries that only notify tool availability.
526
+ */
527
+ const syncMemoryReadPolicy = () => {
528
+ const next = memorySceneAclReadGranted(ctx)
529
+ if (next === memoryReadGranted) return
530
+ memoryReadGranted = next
531
+ }
532
+
533
+ /**
534
+ * Report only what the live registry actually contains. Missing tools mean
535
+ * "not observed/not configured", never "enabled"; Settings distinguishes
536
+ * configured from Scene-authorized and from unverified on-device behavior.
537
+ */
538
+ const syncProviderStatus = () => {
539
+ const media = isRegistered('qq_send_media') && isRegistered('qq_upload_file')
540
+ const qzone = isRegistered('qq_qzone')
541
+ setProviderStatus({
542
+ mediaSend: { installed: media, configured: media, enabled: media },
543
+ qzone: { installed: qzone, configured: qzone, enabled: qzone },
544
+ })
545
+ }
546
+
547
+ // A tools.restrict() change synchronously emits tools/change. A listener that
548
+ // mutates restrictions again would otherwise recurse (and can stall Host
549
+ // activation). The re-entrancy flag turns that synchronous echo into one more
550
+ // bounded pass; every mutation above is idempotent after its first pass.
551
+ let refreshing = false
552
+ let refreshAgain = false
553
+ const refreshVisibility = () => {
554
+ if (refreshing) {
555
+ refreshAgain = true
556
+ return
557
+ }
558
+ refreshing = true
559
+ let passes = 0
560
+ try {
561
+ do {
562
+ refreshAgain = false
563
+ passes += 1
564
+ syncMemoryReadPolicy()
565
+ syncMemoryReaderVisibility()
566
+ hideKnownNonQQ()
567
+ applyVisibleRestriction()
568
+ syncProviderStatus()
569
+ } while (refreshAgain && passes < 8)
570
+ if (refreshAgain) {
571
+ try {
572
+ ctx.logger?.warn?.('amadeus-qq: tool visibility refresh did not settle; execution guard remains fail-closed')
573
+ } catch { /* diagnostics are best-effort */ }
574
+ }
575
+ } finally {
576
+ refreshing = false
577
+ refreshAgain = false
578
+ }
579
+ }
580
+
581
+ refreshVisibility()
582
+
583
+ if (typeof ctx.on === 'function' && typeof ctx.effect === 'function') {
584
+ ctx.effect(() => ctx.on('tools/change', refreshVisibility))
585
+ ctx.effect(() => ctx.on('internal/service', name => {
586
+ // Memory's declaration depends on the live QQ Host service; re-check on
587
+ // either provider change so a connector unload closes visibility too.
588
+ if (name !== MEMORY_SCENE_ACL_SERVICE && name !== 'qq') return
589
+ refreshVisibility()
590
+ }))
591
+ }
592
+ }
593
+