@fg-lab/amadeus-qq 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +55 -53
  3. package/README.zh-CN.md +51 -51
  4. package/REFERENCE.md +49 -49
  5. package/cordis.patch.yml +6 -6
  6. package/docs/DATA_MIGRATION.md +26 -26
  7. package/docs/INSTALL_V1_2.md +22 -22
  8. package/docs/PUBLISHING.md +28 -26
  9. package/docs/RELEASE_CHECKLIST.md +23 -23
  10. package/docs/RELEASE_NOTES.md +38 -38
  11. package/docs/UPGRADE_POLICY.md +26 -26
  12. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  13. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  14. package/lib/client.js +372 -372
  15. package/package.json +94 -91
  16. package/preset.patch.yml +63 -0
  17. package/scripts/symlink-security.mjs +1 -1
  18. package/src/account-profile.js +223 -223
  19. package/src/admin-http.js +361 -361
  20. package/src/audio.js +428 -428
  21. package/src/capabilities.js +785 -785
  22. package/src/client/factory.template.js +82 -82
  23. package/src/client/logic.js +290 -290
  24. package/src/config.js +2 -0
  25. package/src/console/console.css +28 -28
  26. package/src/console/console.js +3 -0
  27. package/src/console/index.html +67 -67
  28. package/src/core.js +21 -4
  29. package/src/files.js +230 -230
  30. package/src/index.js +135 -8
  31. package/src/lease.js +42 -42
  32. package/src/media.js +66 -3
  33. package/src/napcat-action-names.js +186 -186
  34. package/src/native-asr.js +112 -112
  35. package/src/onebot.js +378 -378
  36. package/src/outbound-media.js +309 -309
  37. package/src/owner-workspace.js +394 -394
  38. package/src/permissions.js +20 -0
  39. package/src/preset-guard.mjs +16 -2
  40. package/src/qzone-provider.js +628 -628
  41. package/src/session-provenance.js +74 -74
  42. package/src/setup.js +255 -255
  43. package/src/web-console.js +9 -9
@@ -1,394 +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
- }
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
+ }