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

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