@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,394 @@
1
+ // Restricted Owner workspace: the only sanctioned file surface for the Owner
2
+ // Agent. It never exposes a shell, never accepts absolute paths, and confines
3
+ // every operation to one operator-configured directory. Writes require a real
4
+ // Host approval ask for that single call; a missing approval service denies.
5
+
6
+ import { randomUUID } from 'node:crypto'
7
+ import fs from 'node:fs/promises'
8
+ import path from 'node:path'
9
+
10
+ export const OWNER_WORKSPACE_TOOL = 'qq_owner_workspace'
11
+ export const OWNER_WORKSPACE_CAPABILITY = 'qq.owner.workspace'
12
+ export const DEFAULT_MAX_BYTES = 64 * 1024
13
+ export const MAX_ALLOWED_BYTES = 256 * 1024
14
+ export const MAX_PATH_CHARS = 512
15
+
16
+ const MAX_SEGMENTS = 32
17
+ const DENIED_SEGMENTS = new Set([
18
+ '.credentials.yaml', '.credentials', 'credentials.yaml', 'credentials.yml', 'auth.json',
19
+ ])
20
+
21
+ function ownerError(code, message) {
22
+ const error = new Error(message)
23
+ error.code = code
24
+ return error
25
+ }
26
+
27
+ const PATH_INVALID = () => ownerError('QQ_OWNER_WORKSPACE_PATH_INVALID', 'owner workspace path is invalid')
28
+ const PATH_OUTSIDE = () => ownerError('QQ_OWNER_WORKSPACE_PATH_OUTSIDE', 'owner workspace path escapes the configured directory')
29
+ const SYMLINK = () => ownerError('QQ_OWNER_WORKSPACE_PATH_UNSAFE', 'owner workspace path uses a symbolic link')
30
+ const TOO_LARGE = () => ownerError('QQ_OWNER_WORKSPACE_TOO_LARGE', 'owner workspace content exceeds the configured limit')
31
+ const NOT_UTF8 = () => ownerError('QQ_OWNER_WORKSPACE_NOT_UTF8', 'owner workspace file is not UTF-8 text')
32
+ const UNAVAILABLE = () => ownerError('QQ_OWNER_WORKSPACE_UNAVAILABLE', 'owner workspace is not configured')
33
+ const REVOKED = () => ownerError('QQ_OWNER_WORKSPACE_REVOKED', 'owner workspace authorization is no longer valid')
34
+ const APPROVAL_UNAVAILABLE = () => ownerError('QQ_OWNER_WORKSPACE_APPROVAL_UNAVAILABLE', 'owner workspace write approval is unavailable')
35
+ const UNAPPROVED = () => ownerError('QQ_OWNER_WORKSPACE_UNAPPROVED', 'owner workspace write was not approved')
36
+
37
+ /** Parse the preset-row owner workspace knobs. Invalid values fail activation. */
38
+ export function resolveOwnerWorkspacePolicy(config = {}) {
39
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
40
+ throw new TypeError('owner workspace config must be an object')
41
+ }
42
+ const root = config.ownerWorkspaceRoot === undefined ? '' : config.ownerWorkspaceRoot
43
+ if (typeof root !== 'string') throw new TypeError('ownerWorkspaceRoot must be a string')
44
+ const allowWrite = config.ownerWorkspaceWrites === undefined ? false : config.ownerWorkspaceWrites
45
+ if (typeof allowWrite !== 'boolean') throw new TypeError('ownerWorkspaceWrites must be a boolean')
46
+ const maxBytes = config.ownerWorkspaceMaxBytes === undefined ? DEFAULT_MAX_BYTES : config.ownerWorkspaceMaxBytes
47
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ALLOWED_BYTES) {
48
+ throw new TypeError('ownerWorkspaceMaxBytes must be an integer from 1 to ' + MAX_ALLOWED_BYTES)
49
+ }
50
+ if (root !== '' && (!path.isAbsolute(root) || path.parse(root).root === root)) {
51
+ throw new TypeError('ownerWorkspaceRoot must be an absolute non-root directory')
52
+ }
53
+ return Object.freeze({ root: root === '' ? '' : path.resolve(root), allowWrite, maxBytes })
54
+ }
55
+
56
+ /** True for one path segment that must never be reachable through this surface. */
57
+ export function isDeniedOwnerSegment(segment) {
58
+ if (typeof segment !== 'string' || segment === '' || segment === '.' || segment === '..') return true
59
+ if (segment.includes('\u0000') || segment.includes(':') || segment.includes('/') || segment.includes('\\')) return true
60
+ if (segment.startsWith('.')) return true
61
+ return DENIED_SEGMENTS.has(segment.toLowerCase())
62
+ }
63
+
64
+ /** Split and validate a model-supplied relative path into safe segments. */
65
+ export function ownerPathSegments(requested) {
66
+ if (typeof requested !== 'string') throw PATH_INVALID()
67
+ if (requested.length === 0 || requested.length > MAX_PATH_CHARS) throw PATH_INVALID()
68
+ if (requested.includes('\u0000')) throw PATH_INVALID()
69
+ if (path.isAbsolute(requested) || /^[A-Za-z]:/.test(requested) || /^[\\/]{2}/.test(requested)) throw PATH_INVALID()
70
+ const segments = requested.split(/[\\/]/)
71
+ if (segments.length === 0 || segments.length > MAX_SEGMENTS) throw PATH_INVALID()
72
+ if (segments.some(isDeniedOwnerSegment)) throw PATH_INVALID()
73
+ return segments
74
+ }
75
+
76
+ async function resolveRoot(root) {
77
+ if (typeof root !== 'string' || root === '') throw UNAVAILABLE()
78
+ let real
79
+ try {
80
+ real = await fs.realpath(root)
81
+ } catch {
82
+ throw UNAVAILABLE()
83
+ }
84
+ const stat = await fs.lstat(real)
85
+ if (!stat.isDirectory()) throw UNAVAILABLE()
86
+ if (path.parse(real).root === real) throw UNAVAILABLE()
87
+ return real
88
+ }
89
+
90
+ function contained(root, target) {
91
+ if (target === root) return true
92
+ const relative = path.relative(root, target)
93
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
94
+ }
95
+
96
+ /**
97
+ * Resolve one relative owner path. Existing components must not be symlinks,
98
+ * and the final target must canonicalize inside the configured root.
99
+ */
100
+ export async function resolveOwnerWorkspacePath(root, requested) {
101
+ const segments = ownerPathSegments(requested)
102
+ const rootReal = await resolveRoot(root)
103
+ let cursor = rootReal
104
+ for (let index = 0; index < segments.length; index += 1) {
105
+ cursor = path.join(cursor, segments[index])
106
+ let stat
107
+ try {
108
+ stat = await fs.lstat(cursor)
109
+ } catch (error) {
110
+ if (error?.code === 'ENOENT') break
111
+ throw PATH_INVALID()
112
+ }
113
+ if (stat.isSymbolicLink()) throw SYMLINK()
114
+ if (index < segments.length - 1 && !stat.isDirectory()) throw PATH_INVALID()
115
+ }
116
+ let absolute = path.join(rootReal, ...segments)
117
+ let exists = false
118
+ let targetReal = absolute
119
+ try {
120
+ const stat = await fs.lstat(absolute)
121
+ if (stat.isSymbolicLink()) throw SYMLINK()
122
+ exists = true
123
+ targetReal = await fs.realpath(absolute)
124
+ } catch (error) {
125
+ if (error?.code !== 'ENOENT') throw error?.code ? error : PATH_INVALID()
126
+ }
127
+ if (exists && !contained(rootReal, targetReal)) throw PATH_OUTSIDE()
128
+ if (!exists) {
129
+ const parent = path.dirname(absolute)
130
+ let parentReal
131
+ try {
132
+ parentReal = await fs.realpath(parent)
133
+ } catch {
134
+ throw PATH_INVALID()
135
+ }
136
+ if (!contained(rootReal, parentReal)) throw PATH_OUTSIDE()
137
+ }
138
+ return { absolute, rootReal, relative: segments.join('/'), exists }
139
+ }
140
+
141
+ /** Read one UTF-8 file from the owner workspace. No approval is needed to read. */
142
+ export async function readOwnerWorkspaceFile({ root, requested, maxBytes = DEFAULT_MAX_BYTES, authorize, signal }) {
143
+ if (typeof authorize === 'function') await assertAuthorized(authorize, signal)
144
+ const resolved = await resolveOwnerWorkspacePath(root, requested)
145
+ if (!resolved.exists) throw PATH_INVALID()
146
+ let handle
147
+ try {
148
+ handle = await fs.open(resolved.absolute, 'r')
149
+ } catch {
150
+ throw PATH_INVALID()
151
+ }
152
+ try {
153
+ const stat = await handle.stat()
154
+ if (!stat.isFile()) throw PATH_INVALID()
155
+ if (stat.size > maxBytes) throw TOO_LARGE()
156
+ const bytes = await handle.readFile()
157
+ if (bytes.length > maxBytes) throw TOO_LARGE()
158
+ const text = bytes.toString('utf8')
159
+ if (!Buffer.from(text, 'utf8').equals(bytes)) throw NOT_UTF8()
160
+ // Revocation may land while the read is in flight; recheck before the body
161
+ // is handed to the model.
162
+ if (typeof authorize === 'function') await assertAuthorized(authorize, signal)
163
+ return { name: path.basename(resolved.absolute), bytes: bytes.length, text }
164
+ } finally {
165
+ await handle.close().catch(() => {})
166
+ }
167
+ }
168
+
169
+ function assertNotAborted(signal) {
170
+ if (signal?.aborted) throw REVOKED()
171
+ }
172
+
173
+ async function assertAuthorized(authorize, signal) {
174
+ assertNotAborted(signal)
175
+ if (typeof authorize !== 'function' || (await authorize()) !== true) throw REVOKED()
176
+ }
177
+
178
+ /**
179
+ * Ask the Host approval service for this one write. The request carries only
180
+ * the tool name and a fixed reason; no path, content, credential or URL.
181
+ */
182
+ export async function requestOwnerWriteApproval({ approval, agent, callId, signal }) {
183
+ if (!approval || typeof approval.request !== 'function') throw APPROVAL_UNAVAILABLE()
184
+ let outcome
185
+ try {
186
+ outcome = await approval.request({
187
+ agent,
188
+ toolName: OWNER_WORKSPACE_TOOL,
189
+ callId,
190
+ reason: 'Owner workspace write requires approval',
191
+ signal,
192
+ })
193
+ } catch {
194
+ throw APPROVAL_UNAVAILABLE()
195
+ }
196
+ if (outcome !== 'allowed-once') throw UNAPPROVED()
197
+ }
198
+
199
+ /**
200
+ * Write one UTF-8 file inside the owner workspace. The write is temp-then-rename
201
+ * so a failure leaves any existing file untouched; authorization and the abort
202
+ * signal are rechecked after approval and immediately before the commit.
203
+ */
204
+ export async function writeOwnerWorkspaceFile({
205
+ root, requested, content, maxBytes = DEFAULT_MAX_BYTES,
206
+ authorize, approve, signal,
207
+ }) {
208
+ if (typeof content !== 'string') throw PATH_INVALID()
209
+ const bytes = Buffer.from(content, 'utf8')
210
+ if (bytes.length > maxBytes) throw TOO_LARGE()
211
+ await assertAuthorized(authorize, signal)
212
+ const resolved = await resolveOwnerWorkspacePath(root, requested)
213
+ assertNotAborted(signal)
214
+ if (typeof approve !== 'function') throw APPROVAL_UNAVAILABLE()
215
+ await approve()
216
+ await assertAuthorized(authorize, signal)
217
+ assertNotAborted(signal)
218
+ // Re-resolve after approval so a replacement during the ask cannot redirect us.
219
+ const confirmed = await resolveOwnerWorkspacePath(root, requested)
220
+ if (confirmed.absolute !== resolved.absolute) throw PATH_INVALID()
221
+ const temporary = path.join(path.dirname(confirmed.absolute), '.qq-owner-' + randomUUID() + '.tmp')
222
+ let created = true
223
+ try {
224
+ const existing = await fs.lstat(confirmed.absolute)
225
+ created = !existing.isFile()
226
+ } catch (error) {
227
+ if (error?.code !== 'ENOENT') throw PATH_INVALID()
228
+ }
229
+ let handle
230
+ try {
231
+ handle = await fs.open(temporary, 'wx', 0o600)
232
+ try {
233
+ await handle.writeFile(bytes)
234
+ await handle.sync()
235
+ } finally {
236
+ await handle.close()
237
+ }
238
+ if (signal?.aborted) throw REVOKED()
239
+ await assertAuthorized(authorize, signal)
240
+ await fs.rename(temporary, confirmed.absolute)
241
+ } catch (error) {
242
+ await fs.rm(temporary, { force: true }).catch(() => {})
243
+ throw error?.code ? error : PATH_INVALID()
244
+ }
245
+ return { name: path.basename(confirmed.absolute), bytes: bytes.length, created }
246
+ }
247
+
248
+ /** Fixed, model-facing text; never includes an absolute path or raw reason. */
249
+ function ownerErrorText(error) {
250
+ switch (error?.code) {
251
+ case 'QQ_OWNER_WORKSPACE_PATH_INVALID': return 'Invalid owner workspace path.'
252
+ case 'QQ_OWNER_WORKSPACE_PATH_OUTSIDE': return 'Path is outside the owner workspace.'
253
+ case 'QQ_OWNER_WORKSPACE_PATH_UNSAFE': return 'Symbolic links are not allowed in the owner workspace.'
254
+ case 'QQ_OWNER_WORKSPACE_TOO_LARGE': return 'File exceeds the owner workspace size limit.'
255
+ case 'QQ_OWNER_WORKSPACE_NOT_UTF8': return 'Only UTF-8 text files can be read here.'
256
+ case 'QQ_OWNER_WORKSPACE_APPROVAL_UNAVAILABLE': return 'Owner workspace write approval is unavailable; nothing was written.'
257
+ case 'QQ_OWNER_WORKSPACE_UNAPPROVED': return 'Owner workspace write was not approved; nothing was written.'
258
+ case 'QQ_OWNER_WORKSPACE_REVOKED': return 'Owner workspace authorization is no longer valid.'
259
+ default: return 'Owner workspace operation failed.'
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Build the model-facing tool. It is only registered by an Owner preset
265
+ * (level 4) that also configures a root; it is never visible otherwise.
266
+ */
267
+ export function createOwnerWorkspaceTool({ root, allowWrite, maxBytes, getService, getApproval }) {
268
+ return {
269
+ name: OWNER_WORKSPACE_TOOL,
270
+ description: 'Read or write UTF-8 files inside the configured Owner workspace only. This is not a shell and cannot reach other directories.',
271
+ parameters: {
272
+ type: 'object',
273
+ additionalProperties: false,
274
+ properties: {
275
+ op: { type: 'string', enum: ['read', 'write'] },
276
+ path: { type: 'string', description: 'Relative path inside the Owner workspace.' },
277
+ content: { type: 'string', description: 'UTF-8 text to write (write only).' },
278
+ },
279
+ required: ['op', 'path'],
280
+ },
281
+ output: {
282
+ schema: { type: 'object', additionalProperties: true },
283
+ render: (_args, value) => [{
284
+ type: 'text',
285
+ text: value?.op === 'read'
286
+ ? String(value.text ?? '')
287
+ : JSON.stringify({ op: value?.op, name: value?.name, bytes: value?.bytes, created: value?.created }),
288
+ }],
289
+ },
290
+ async execute(args, exec) {
291
+ const sessionId = exec?.agent?.session?.id
292
+ if (typeof sessionId !== 'string' || !sessionId) throw new Error('Owner workspace requires a bound DSH Session.')
293
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Owner workspace arguments are invalid.')
294
+ const keys = Object.keys(args)
295
+ if (keys.some(key => !['op', 'path', 'content'].includes(key))) throw new Error('Owner workspace arguments are invalid.')
296
+ if (args.op !== 'read' && args.op !== 'write') throw new Error('Owner workspace op must be read or write.')
297
+ if (typeof args.path !== 'string') throw new Error('Owner workspace path is invalid.')
298
+ if (args.op === 'write' && typeof args.content !== 'string') throw new Error('Owner workspace write requires content.')
299
+ const service = typeof getService === 'function' ? getService() : undefined
300
+ const authorize = () => {
301
+ try {
302
+ const scene = service?.resolveTrustedSessionScene?.(sessionId)
303
+ if (!scene || scene.admin !== true) return false
304
+ return service?.authorizeSessionCapability?.(sessionId, OWNER_WORKSPACE_CAPABILITY) === true
305
+ } catch {
306
+ return false
307
+ }
308
+ }
309
+ try {
310
+ if (args.op === 'read') {
311
+ if (!authorize()) throw REVOKED()
312
+ const file = await readOwnerWorkspaceFile({
313
+ root,
314
+ requested: args.path,
315
+ maxBytes,
316
+ authorize,
317
+ signal: exec?.signal,
318
+ })
319
+ return {
320
+ op: 'read',
321
+ name: file.name,
322
+ bytes: file.bytes,
323
+ text: file.text,
324
+ }
325
+ }
326
+ if (!allowWrite) throw ownerError('QQ_OWNER_WORKSPACE_WRITE_DISABLED', 'owner workspace writes are disabled')
327
+ if (!authorize()) throw REVOKED()
328
+ const result = await writeOwnerWorkspaceFile({
329
+ root,
330
+ requested: args.path,
331
+ content: args.content,
332
+ maxBytes,
333
+ signal: exec?.signal,
334
+ authorize,
335
+ approve: () => requestOwnerWriteApproval({
336
+ approval: typeof getApproval === 'function' ? getApproval() : undefined,
337
+ agent: exec?.agent,
338
+ callId: exec?.callId,
339
+ signal: exec?.signal,
340
+ }),
341
+ })
342
+ return { op: 'write', name: result.name, bytes: result.bytes, created: result.created }
343
+ } catch (error) {
344
+ throw new Error(ownerErrorText(error))
345
+ }
346
+ },
347
+ }
348
+ }
349
+
350
+ // Read-only management metadata for the Settings page. No absolute path, no
351
+ // file list: only whether the feature is configured and what it allows.
352
+ let runtimeStatus = Object.freeze({ configured: false, writes: false, maxBytes: DEFAULT_MAX_BYTES })
353
+
354
+ export function setOwnerWorkspaceStatus(status) {
355
+ const source = status ?? {}
356
+ runtimeStatus = Object.freeze({
357
+ configured: source.configured === true,
358
+ writes: source.writes === true,
359
+ maxBytes: Number.isSafeInteger(source.maxBytes) && source.maxBytes > 0 ? source.maxBytes : DEFAULT_MAX_BYTES,
360
+ })
361
+ }
362
+
363
+ export function ownerWorkspaceStatus() {
364
+ return runtimeStatus
365
+ }
366
+
367
+ export function resetOwnerWorkspaceStatus() {
368
+ runtimeStatus = Object.freeze({ configured: false, writes: false, maxBytes: DEFAULT_MAX_BYTES })
369
+ }
370
+
371
+ // Provider status for the Settings page. No process starts or credentials are
372
+ // read here; the connector wires these once media send and QZone exist.
373
+ const EMPTY_PROVIDER = Object.freeze({ installed: false, configured: false, enabled: false })
374
+ let providerRuntime = Object.freeze({ mediaSend: EMPTY_PROVIDER, qzone: EMPTY_PROVIDER })
375
+
376
+ export function setProviderStatus(patch = {}) {
377
+ const pick = source => Object.freeze({
378
+ installed: source?.installed === true,
379
+ configured: source?.configured === true,
380
+ enabled: source?.enabled === true,
381
+ })
382
+ providerRuntime = Object.freeze({
383
+ mediaSend: pick(patch.mediaSend ?? providerRuntime.mediaSend),
384
+ qzone: pick(patch.qzone ?? providerRuntime.qzone),
385
+ })
386
+ }
387
+
388
+ export function providerStatus() {
389
+ return providerRuntime
390
+ }
391
+
392
+ export function resetProviderStatus() {
393
+ providerRuntime = Object.freeze({ mediaSend: EMPTY_PROVIDER, qzone: EMPTY_PROVIDER })
394
+ }
@@ -0,0 +1,250 @@
1
+ // Capability decisions for a trusted QQ Scene. The caller must obtain Scene
2
+ // identity from the platform adapter/Session binding, never from model args.
3
+
4
+ export const MODE_IDS = Object.freeze([
5
+ 'observe', 'safe-chat', 'enhanced', 'autonomous-social', 'owner-agent',
6
+ ])
7
+
8
+ export const BEHAVIOR_PRESETS = Object.freeze({
9
+ observe: Object.freeze({ activity: 'low', quietWindowMs: 0, waitTimeoutMs: 0, replyCooldownMs: 0, contextBudget: 20 }),
10
+ 'safe-chat': Object.freeze({ activity: 'low', quietWindowMs: 5000, waitTimeoutMs: 30000, replyCooldownMs: 0, contextBudget: 20 }),
11
+ enhanced: Object.freeze({ activity: 'medium', quietWindowMs: 10000, waitTimeoutMs: 30000, replyCooldownMs: 0, contextBudget: 20 }),
12
+ 'autonomous-social': Object.freeze({ activity: 'medium', quietWindowMs: 12000, waitTimeoutMs: 30000, replyCooldownMs: 15000, contextBudget: 20 }),
13
+ 'owner-agent': Object.freeze({ activity: 'medium', quietWindowMs: 10000, waitTimeoutMs: 30000, replyCooldownMs: 0, contextBudget: 20 }),
14
+ })
15
+ const BEHAVIOR_VALUES = Object.freeze({
16
+ activity: ['low', 'medium', 'high'], quietWindowMs: [0, 5000, 10000, 12000, 15000],
17
+ waitTimeoutMs: [0, 15000, 30000, 60000, 120000],
18
+ replyCooldownMs: [0, 10000, 15000, 30000, 60000], contextBudget: [10, 20, 40],
19
+ })
20
+
21
+ function validateBehavior(behavior) {
22
+ if (!behavior || typeof behavior !== 'object' || Array.isArray(behavior) ||
23
+ Object.keys(behavior).length !== Object.keys(BEHAVIOR_VALUES).length) {
24
+ throw new TypeError('Scene behavior must be a complete preset-compatible object')
25
+ }
26
+ for (const [key, values] of Object.entries(BEHAVIOR_VALUES)) {
27
+ if (!values.includes(behavior[key])) throw new TypeError(`Invalid Scene behavior: ${key}`)
28
+ }
29
+ if (Object.keys(behavior).some(key => !Object.hasOwn(BEHAVIOR_VALUES, key))) {
30
+ throw new TypeError('Unknown Scene behavior field')
31
+ }
32
+ return { ...behavior }
33
+ }
34
+
35
+ const GROUP_WRITE_DEFAULTS = [
36
+ 'qq.group.essence', 'qq.group.notice', 'qq.group.card',
37
+ 'qq.group.ban', 'qq.group.whole_ban', 'qq.group.kick',
38
+ 'qq.group.name', 'qq.group.admin', 'qq.group.special_title',
39
+ ]
40
+ const MODE_TO_CAPABILITIES = Object.freeze({
41
+ observe: ['qq.read'],
42
+ 'safe-chat': ['qq.read', 'qq.send'],
43
+ enhanced: ['qq.read', 'qq.send', 'dsh.search', 'qq.media.read', 'qq.reaction.write', 'qq.poke', 'qq.recall'],
44
+ 'autonomous-social': ['qq.read', 'qq.send', 'dsh.search', 'qq.media.read', 'qq.reaction.write', 'qq.poke', 'qq.recall'],
45
+ 'owner-agent': ['qq.read', 'qq.send', 'dsh.search', 'qq.media.read', 'dsh.shell', 'qq.reaction.write', 'qq.poke', 'qq.recall'],
46
+ })
47
+
48
+ /**
49
+ * Capability catalog shared by the decision engine and the management API.
50
+ * `implemented` means this plugin has a real execution path today; false marks
51
+ * decision-only capabilities that must never be presented as usable.
52
+ */
53
+ export const CAPABILITY_CATALOG = Object.freeze([
54
+ Object.freeze({ id: 'qq.read', implemented: true }),
55
+ Object.freeze({ id: 'qq.send', implemented: true }),
56
+ Object.freeze({ id: 'dsh.search', implemented: true }),
57
+ Object.freeze({ id: 'qq.friends.read', implemented: true }),
58
+ Object.freeze({ id: 'qq.groups.read', implemented: true }),
59
+ Object.freeze({ id: 'qq.media.read', implemented: true }),
60
+ Object.freeze({ id: 'qq.owner.workspace', implemented: true }),
61
+ Object.freeze({ id: 'qq.owner.tools', implemented: true }),
62
+ Object.freeze({ id: 'qq.reaction.write', implemented: true }),
63
+ Object.freeze({ id: 'qq.poke', implemented: true }),
64
+ Object.freeze({ id: 'qq.recall', implemented: true }),
65
+ Object.freeze({ id: 'qq.group.essence', implemented: true }),
66
+ Object.freeze({ id: 'qq.group.notice', implemented: true }),
67
+ Object.freeze({ id: 'qq.group.card', implemented: true }),
68
+ Object.freeze({ id: 'qq.group.ban', implemented: true }),
69
+ Object.freeze({ id: 'qq.group.whole_ban', implemented: true }),
70
+ Object.freeze({ id: 'qq.group.kick', implemented: true }),
71
+ Object.freeze({ id: 'qq.group.name', implemented: true }),
72
+ Object.freeze({ id: 'qq.group.admin', implemented: true }),
73
+ Object.freeze({ id: 'qq.group.special_title', implemented: true }),
74
+ Object.freeze({ id: 'qq.profile.write', implemented: true }),
75
+ Object.freeze({ id: 'qq.qzone.use', implemented: true }),
76
+ Object.freeze({ id: 'qq.qzone.read', implemented: true }),
77
+ Object.freeze({ id: 'qq.qzone.publish', implemented: true }),
78
+ Object.freeze({ id: 'dsh.shell', implemented: false }),
79
+ ])
80
+ const KNOWN_CAPABILITIES = new Set(CAPABILITY_CATALOG.map(item => item.id))
81
+ const DIRECTORY_CAPABILITIES = new Set(['qq.friends.read', 'qq.groups.read'])
82
+ const QZONE_CAPABILITIES = new Set(['qq.qzone.use', 'qq.qzone.read', 'qq.qzone.publish'])
83
+ const GROUP_WRITE_CAPABILITIES = new Set(GROUP_WRITE_DEFAULTS)
84
+ const OVERRIDE_VALUES = new Set(['inherit', 'allow', 'deny'])
85
+ const ID_PATTERN = /^[1-9]\d{0,19}$/
86
+
87
+ function validMode(mode) { return MODE_IDS.includes(mode) }
88
+
89
+ export function validateScenePermissions(access, { requireRevision = false, allowRevision = true } = {}) {
90
+ if (!access || typeof access !== 'object' || Array.isArray(access)) throw new TypeError('Scene permissions must be an object')
91
+ const keys = new Set(allowRevision ? ['mode', 'overrides', 'behavior', 'revision'] : ['mode', 'overrides', 'behavior'])
92
+ for (const key of Object.keys(access)) if (!keys.has(key)) throw new TypeError(`Unknown Scene permission field: ${key}`)
93
+ if (!validMode(access.mode)) throw new TypeError('Invalid Scene permission mode')
94
+ const overrides = access.overrides ?? {}
95
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) throw new TypeError('Scene permission overrides must be an object')
96
+ for (const [capability, value] of Object.entries(overrides)) {
97
+ if (!KNOWN_CAPABILITIES.has(capability) || !OVERRIDE_VALUES.has(value)) {
98
+ throw new TypeError(`Invalid Scene capability override: ${capability}`)
99
+ }
100
+ }
101
+ if ((requireRevision || access.revision !== undefined) && (!Number.isSafeInteger(access.revision) || access.revision < 1)) {
102
+ throw new TypeError('Invalid Scene permission revision')
103
+ }
104
+ return { mode: access.mode, overrides: { ...overrides },
105
+ ...(access.behavior === undefined ? {} : { behavior: validateBehavior(access.behavior) }) }
106
+ }
107
+
108
+ function idSet(values, label) {
109
+ if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`)
110
+ const result = new Set()
111
+ for (const value of values) {
112
+ const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
113
+ if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw new TypeError(`${label} contains an invalid platform ID`)
114
+ result.add(id)
115
+ }
116
+ return result
117
+ }
118
+
119
+ function stringSet(values, label) {
120
+ if (!Array.isArray(values)) throw new TypeError(`${label} must be an array`)
121
+ const result = new Set(values)
122
+ for (const value of result) if (!KNOWN_CAPABILITIES.has(value)) throw new TypeError(`${label} contains an unknown capability`)
123
+ return result
124
+ }
125
+
126
+ function accountCanPublish(grants, botId) {
127
+ const account = grants?.byBotId?.[botId]
128
+ return account?.qzonePublish === true
129
+ }
130
+
131
+ export class PermissionEngine {
132
+ constructor(raw = {}) {
133
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new TypeError('Permission policy must be an object')
134
+ this.defaultMode = raw.defaultMode ?? 'safe-chat'
135
+ this.groupDefaultMode = raw.groupDefaultMode ?? this.defaultMode
136
+ this.privateDefaultMode = raw.privateDefaultMode ?? this.defaultMode
137
+ for (const mode of [this.defaultMode, this.groupDefaultMode, this.privateDefaultMode]) {
138
+ if (!validMode(mode)) throw new TypeError('Invalid default permission mode')
139
+ }
140
+ // A group is never an Owner Agent Scene, even by default. Reject at
141
+ // construction so a GUI cannot inherit and display that configuration.
142
+ if (this.groupDefaultMode === 'owner-agent') {
143
+ throw new TypeError('groupDefaultMode cannot be owner-agent: groups cannot be Owner Agent Scenes')
144
+ }
145
+ this.ownerPrivateIds = idSet(raw.ownerPrivateIds ?? [], 'ownerPrivateIds')
146
+ this.ownerTools = stringSet(raw.ownerTools ?? [], 'ownerTools')
147
+ this.globalDeny = stringSet(raw.globalDeny ?? [], 'globalDeny')
148
+ this.accountGrants = raw.accountGrants ?? {}
149
+ if (!this.accountGrants || typeof this.accountGrants !== 'object' || Array.isArray(this.accountGrants)) {
150
+ throw new TypeError('accountGrants must be an object')
151
+ }
152
+ }
153
+
154
+ evaluate(scene, access, capability) {
155
+ if (!KNOWN_CAPABILITIES.has(capability)) return { allowed: false, mode: null, reason: 'unknown-capability' }
156
+ if (!scene || !['group', 'private'].includes(scene.kind) ||
157
+ typeof scene.botId !== 'string' || !ID_PATTERN.test(scene.botId) ||
158
+ typeof scene.peerId !== 'string' || !ID_PATTERN.test(scene.peerId)) {
159
+ return { allowed: false, mode: null, reason: 'invalid-scene' }
160
+ }
161
+ let normalized
162
+ try {
163
+ normalized = access == null ? null : validateScenePermissions(access)
164
+ } catch {
165
+ return { allowed: false, mode: null, reason: 'invalid-scene-access' }
166
+ }
167
+ const mode = normalized?.mode ?? (scene.kind === 'group' ? this.groupDefaultMode : this.privateDefaultMode)
168
+ const decision = (allowed, reason) => ({ allowed, mode, reason })
169
+ if (this.globalDeny.has(capability)) return decision(false, 'global-deny')
170
+ if (QZONE_CAPABILITIES.has(capability)) {
171
+ // A unified revoke must also close the legacy read/publish aliases.
172
+ for (const related of QZONE_CAPABILITIES) {
173
+ if (this.globalDeny.has(related)) return decision(false, 'global-deny')
174
+ if (normalized?.overrides?.[related] === 'deny') return decision(false, 'scene-deny')
175
+ }
176
+ }
177
+ const owner = scene.kind === 'private' && this.ownerPrivateIds.has(scene.peerId)
178
+ if (mode === 'owner-agent' && !owner) return decision(false, 'owner-private-required')
179
+ const override = normalized?.overrides?.[capability] ?? 'inherit'
180
+ if (GROUP_WRITE_CAPABILITIES.has(capability) && scene.kind !== 'group') {
181
+ return decision(false, 'group-only')
182
+ }
183
+ if (DIRECTORY_CAPABILITIES.has(capability)) {
184
+ // Account-wide directories are never inherited from the Owner mode and
185
+ // never granted by a non-owner Scene; each call needs an explicit allow on
186
+ // a verified owner private Scene in owner-agent mode.
187
+ if (!owner || mode !== 'owner-agent' || override !== 'allow') {
188
+ return decision(false, 'owner-directory-required')
189
+ }
190
+ return decision(true, 'scene-allow')
191
+ }
192
+ if (capability === 'qq.owner.tools') {
193
+ // Native Host tools are available only for a verified owner private
194
+ // Scene in owner-agent mode; no extra per-tool approval is required.
195
+ if (!owner || mode !== 'owner-agent' || override === 'deny') {
196
+ return decision(false, 'owner-tools-required')
197
+ }
198
+ return decision(true, 'owner-agent')
199
+ }
200
+ if (capability === 'qq.owner.workspace') {
201
+ // The file surface is never implied by owner-agent mode and is never
202
+ // reachable from Safe Chat; the Owner must explicitly allow this Scene.
203
+ if (!owner || mode !== 'owner-agent' || override !== 'allow') {
204
+ return decision(false, 'owner-workspace-required')
205
+ }
206
+ return decision(true, 'scene-allow')
207
+ }
208
+ if (capability === 'qq.qzone.use') {
209
+ // Unified QZone access covers reads and writes, but still requires the
210
+ // account-level grant and an explicit Scene grant in autonomous-social or
211
+ // a verified owner private owner-agent Scene.
212
+ if (override !== 'allow') return decision(false, 'qzone-use-explicit')
213
+ if (!accountCanPublish(this.accountGrants, scene.botId)) {
214
+ return decision(false, 'account-publish-required')
215
+ }
216
+ if (owner && mode === 'owner-agent') return decision(true, 'scene-allow')
217
+ if (mode === 'autonomous-social') return decision(true, 'scene-allow')
218
+ return decision(false, 'qzone-use-explicit')
219
+ }
220
+ if (capability === 'qq.qzone.read') {
221
+ // QZone reads expose account-wide content. Require either an explicit
222
+ // Owner private grant or an explicitly allowed Autonomous Social Scene.
223
+ if (override !== 'allow') return decision(false, 'qzone-read-explicit')
224
+ if (owner && mode === 'owner-agent') return decision(true, 'scene-allow')
225
+ if (mode === 'autonomous-social') return decision(true, 'scene-allow')
226
+ return decision(false, 'qzone-read-explicit')
227
+ }
228
+ if (capability === 'qq.profile.write') {
229
+ // Account profile writes are global and require a verified Owner private
230
+ // Scene, owner-agent mode and an explicit Scene allow.
231
+ if (!owner || mode !== 'owner-agent' || override !== 'allow') {
232
+ return decision(false, 'profile-write-explicit')
233
+ }
234
+ return decision(true, 'scene-allow')
235
+ }
236
+ if (capability === 'dsh.shell' && (!owner || mode !== 'owner-agent' || !this.ownerTools.has(capability))) {
237
+ return decision(false, 'owner-tool-required')
238
+ }
239
+ if (capability === 'qq.qzone.publish') {
240
+ if (override !== 'allow') return decision(false, 'qzone-publish-explicit')
241
+ if (!accountCanPublish(this.accountGrants, scene.botId)) return decision(false, 'account-publish-required')
242
+ if (owner && mode === 'owner-agent') return decision(true, 'scene-allow')
243
+ if (mode === 'autonomous-social') return decision(true, 'scene-allow')
244
+ return decision(false, 'qzone-publish-explicit')
245
+ }
246
+ if (override === 'deny') return decision(false, 'scene-deny')
247
+ if (override === 'allow') return decision(true, 'scene-allow')
248
+ return decision(MODE_TO_CAPABILITIES[mode].includes(capability), 'mode')
249
+ }
250
+ }