@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.
- package/LICENSE +21 -21
- package/README.md +55 -53
- package/README.zh-CN.md +51 -51
- package/REFERENCE.md +49 -49
- package/cordis.patch.yml +6 -6
- package/docs/DATA_MIGRATION.md +26 -26
- package/docs/INSTALL_V1_2.md +22 -22
- package/docs/PUBLISHING.md +28 -26
- package/docs/RELEASE_CHECKLIST.md +23 -23
- package/docs/RELEASE_NOTES.md +38 -38
- package/docs/UPGRADE_POLICY.md +26 -26
- package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
- package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
- package/lib/client.js +372 -372
- package/package.json +94 -91
- package/preset.patch.yml +63 -0
- package/scripts/symlink-security.mjs +1 -1
- package/src/account-profile.js +223 -223
- package/src/admin-http.js +361 -361
- package/src/audio.js +428 -428
- package/src/capabilities.js +785 -785
- package/src/client/factory.template.js +82 -82
- package/src/client/logic.js +290 -290
- package/src/config.js +2 -0
- package/src/console/console.css +28 -28
- package/src/console/console.js +3 -0
- package/src/console/index.html +67 -67
- package/src/core.js +21 -4
- package/src/files.js +230 -230
- package/src/index.js +135 -8
- package/src/lease.js +42 -42
- package/src/media.js +66 -3
- package/src/napcat-action-names.js +186 -186
- package/src/native-asr.js +112 -112
- package/src/onebot.js +378 -378
- package/src/outbound-media.js +309 -309
- package/src/owner-workspace.js +394 -394
- package/src/permissions.js +20 -0
- package/src/preset-guard.mjs +16 -2
- package/src/qzone-provider.js +628 -628
- package/src/session-provenance.js +74 -74
- package/src/setup.js +255 -255
- package/src/web-console.js +9 -9
package/src/owner-workspace.js
CHANGED
|
@@ -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
|
+
}
|