@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,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
|
+
}
|