@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.
- package/LICENSE +21 -0
- package/README.md +53 -3
- package/README.zh-CN.md +51 -0
- package/REFERENCE.md +49 -0
- package/assets/market-icon.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/COMPATIBILITY.md +17 -0
- package/docs/DATA_MIGRATION.md +26 -0
- package/docs/INSTALL_V1_2.md +22 -0
- package/docs/PUBLISHING.md +26 -0
- package/docs/RELEASE_CHECKLIST.md +23 -0
- package/docs/RELEASE_NOTES.md +38 -0
- package/docs/UPGRADE_POLICY.md +26 -0
- package/docs/V1_SCOPE.md +33 -0
- package/docs/screenshots/account-browser.png +0 -0
- package/docs/screenshots/account-dark-browser.png +0 -0
- package/docs/screenshots/global-desktop.png +0 -0
- package/docs/screenshots/overview-desktop.png +0 -0
- package/docs/screenshots/product-release-20261031.png +0 -0
- package/docs/screenshots/v1.2-overview.png +0 -0
- package/docs/v1.1/PRESET_MATRIX.md +28 -0
- package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
- package/docs/v1.1/TEST_REPORT.md +20 -0
- package/docs/v1.1/UPGRADE.md +10 -0
- package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
- package/lib/client.js +380 -0
- package/package.json +91 -6
- package/preset.patch.yml +103 -0
- package/screenshots.json +6 -0
- package/scripts/build-client.mjs +52 -0
- package/scripts/registry-guard.mjs +12 -0
- package/scripts/release-audit.mjs +59 -0
- package/scripts/symlink-security.mjs +8 -0
- package/src/account-profile.js +223 -0
- package/src/admin-http.js +361 -0
- package/src/audio.js +428 -0
- package/src/capabilities.js +785 -0
- package/src/client/factory.template.js +82 -0
- package/src/client/logic.js +290 -0
- package/src/config.js +48 -0
- package/src/console/console.css +28 -0
- package/src/console/console.js +538 -0
- package/src/console/index.html +67 -0
- package/src/core.js +892 -0
- package/src/files.js +230 -0
- package/src/index.js +867 -0
- package/src/lease.js +42 -0
- package/src/media.js +246 -0
- package/src/napcat-action-names.js +186 -0
- package/src/native-asr.js +112 -0
- package/src/onebot.js +378 -0
- package/src/outbound-media.js +309 -0
- package/src/owner-workspace.js +394 -0
- package/src/permissions.js +250 -0
- package/src/preset-guard.mjs +579 -0
- package/src/qzone-provider.js +628 -0
- package/src/session-provenance.js +74 -0
- package/src/setup.js +255 -0
- package/src/storage.js +580 -0
- 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
|
+
}
|