dsh-bailinghub 0.2.0 → 0.4.0
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/CHANGELOG.md +70 -0
- package/PRIVACY.md +108 -9
- package/README.md +129 -148
- package/SECURITY.md +114 -4
- package/docs/AGENT_CLIENT_CONTRACT.md +330 -23
- package/docs/COMPATIBILITY.md +75 -12
- package/docs/GETTING_STARTED.md +126 -0
- package/docs/GETTING_STARTED.zh-CN.md +113 -0
- package/docs/MIGRATION_VNEXT.md +106 -22
- package/docs/PROJECT_BOUNDARIES.md +4 -2
- package/docs/README.zh-CN.md +98 -132
- package/lib/authorizations.js +61 -0
- package/lib/conversation-archive-store.js +265 -0
- package/lib/conversation-outbox.js +230 -0
- package/lib/index.js +3 -0
- package/lib/runtime.js +1070 -64
- package/lib/session-scope-store.js +265 -0
- package/lib/session-scope.js +319 -0
- package/lib/transport.js +16 -2
- package/package.json +14 -4
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
7
|
+
|
|
8
|
+
const SCHEMA = 'bailing.agent-session-scope.v1'
|
|
9
|
+
const MAX_BYTES = 256 * 1024
|
|
10
|
+
const LOCK_TIMEOUT_MS = 1_000
|
|
11
|
+
const SECRET_KEYS = new Set(['token', 'accesstoken', 'refreshtoken', 'password', 'clientsecret', 'authorization', 'credential', 'credentials'])
|
|
12
|
+
|
|
13
|
+
function storeError(code = 'SCOPE_STORE_UNAVAILABLE') {
|
|
14
|
+
const error = new Error(code === 'SCOPE_STORE_CONFLICT'
|
|
15
|
+
? 'The session scope changed or is being saved. Read it again before retrying.'
|
|
16
|
+
: 'The session scope store is unavailable. The scope was not safely confirmed.')
|
|
17
|
+
error.code = code
|
|
18
|
+
return error
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeError(error) {
|
|
22
|
+
return ['SCOPE_STORE_CONFLICT', 'SCOPE_STORE_UNAVAILABLE'].includes(error?.code)
|
|
23
|
+
? error : storeError()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sessionKey(sessionId) {
|
|
27
|
+
if (typeof sessionId !== 'string' || !sessionId.length || sessionId.length > 4_096) throw storeError()
|
|
28
|
+
return createHash('sha256').update(sessionId).digest('hex')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The coordinator owns the scope schema. This boundary additionally refuses
|
|
32
|
+
// non-JSON values and credential fields so stores cannot become credential caches.
|
|
33
|
+
function snapshotJson(value, seen = new Set()) {
|
|
34
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
35
|
+
if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) return value
|
|
36
|
+
if (!value || typeof value !== 'object' || seen.has(value)) throw storeError()
|
|
37
|
+
const array = Array.isArray(value)
|
|
38
|
+
if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw storeError()
|
|
39
|
+
seen.add(value)
|
|
40
|
+
try {
|
|
41
|
+
const entries = Reflect.ownKeys(value).filter((key) => !array || key !== 'length')
|
|
42
|
+
if (array && (entries.length !== value.length || entries.some((key, index) => key !== String(index)))) throw storeError()
|
|
43
|
+
const copy = array ? [] : {}
|
|
44
|
+
for (const key of entries) {
|
|
45
|
+
const property = Object.getOwnPropertyDescriptor(value, key)
|
|
46
|
+
if (typeof key !== 'string' || !property.enumerable || !Object.hasOwn(property, 'value') ||
|
|
47
|
+
SECRET_KEYS.has(key.replace(/[_-]/gu, '').toLowerCase()) || ['__proto__', 'constructor', 'prototype'].includes(key)) throw storeError()
|
|
48
|
+
copy[key] = snapshotJson(property.value, seen)
|
|
49
|
+
}
|
|
50
|
+
return copy
|
|
51
|
+
} finally {
|
|
52
|
+
seen.delete(value)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function snapshotRecord(sessionId, record) {
|
|
57
|
+
const copy = snapshotJson(record)
|
|
58
|
+
if (!copy || Array.isArray(copy) || copy.schema !== SCHEMA || copy.sessionId !== sessionId ||
|
|
59
|
+
!Number.isSafeInteger(copy.revision) || copy.revision < 1) throw storeError()
|
|
60
|
+
if (Buffer.byteLength(JSON.stringify(copy)) > MAX_BYTES) throw storeError()
|
|
61
|
+
return copy
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nextRecord(sessionId, record, expectedRevision) {
|
|
65
|
+
sessionKey(sessionId)
|
|
66
|
+
if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)) throw storeError()
|
|
67
|
+
const copy = snapshotRecord(sessionId, record)
|
|
68
|
+
if (copy.revision !== (expectedRevision ?? 0) + 1) throw storeError()
|
|
69
|
+
return copy
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function assertRevision(current, expectedRevision) {
|
|
73
|
+
if ((current?.revision ?? null) !== expectedRevision) throw storeError('SCOPE_STORE_CONFLICT')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertOwned(stat, kind) {
|
|
77
|
+
if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile()) ||
|
|
78
|
+
(kind === 'file' && stat.nlink !== 1) ||
|
|
79
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
|
|
80
|
+
(process.platform !== 'win32' && (stat.mode & 0o777) !== (kind === 'directory' ? 0o700 : 0o600))) throw storeError()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sameFile(a, b) {
|
|
84
|
+
return a.dev === b.dev && a.ino === b.ino
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function defaultDirectory() {
|
|
88
|
+
const configured = process.env.DSH_HOME
|
|
89
|
+
const base = typeof configured === 'string' && configured.trim() ? configured : join(homedir(), '.dsh')
|
|
90
|
+
const expanded = base === '~' ? homedir() : /^~[/\\]/u.test(base) ? join(homedir(), base.slice(2)) : base
|
|
91
|
+
return join(resolve(expanded), 'plugins', 'dsh-bailinghub', 'session-scopes')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function ensureDirectory(directory, create) {
|
|
95
|
+
if (create) await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
96
|
+
let stat
|
|
97
|
+
try {
|
|
98
|
+
stat = await lstat(directory)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!create && error.code === 'ENOENT') return false
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
assertOwned(stat, 'directory')
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readRecord(path, sessionId) {
|
|
108
|
+
let before
|
|
109
|
+
try {
|
|
110
|
+
before = await lstat(path)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error.code === 'ENOENT') return null
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
assertOwned(before, 'file')
|
|
116
|
+
if (before.size > MAX_BYTES) throw storeError()
|
|
117
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
118
|
+
try {
|
|
119
|
+
const opened = await file.stat()
|
|
120
|
+
assertOwned(opened, 'file')
|
|
121
|
+
if (!sameFile(before, opened) || opened.size > MAX_BYTES) throw storeError()
|
|
122
|
+
// Bound the read even if an abnormal writer grows the file after fstat.
|
|
123
|
+
const bytes = Buffer.alloc(MAX_BYTES + 1)
|
|
124
|
+
let length = 0
|
|
125
|
+
while (length < bytes.length) {
|
|
126
|
+
const result = await file.read(bytes, length, bytes.length - length, length)
|
|
127
|
+
if (!result.bytesRead) break
|
|
128
|
+
length += result.bytesRead
|
|
129
|
+
}
|
|
130
|
+
if (length > MAX_BYTES) throw storeError()
|
|
131
|
+
return snapshotRecord(sessionId, JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length))))
|
|
132
|
+
} finally {
|
|
133
|
+
await file.close()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function acquireLock(path) {
|
|
138
|
+
const deadline = performance.now() + LOCK_TIMEOUT_MS
|
|
139
|
+
while (true) {
|
|
140
|
+
let file
|
|
141
|
+
let stat
|
|
142
|
+
try {
|
|
143
|
+
file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
144
|
+
stat = await file.stat()
|
|
145
|
+
await file.chmod(0o600)
|
|
146
|
+
assertOwned(await file.stat(), 'file')
|
|
147
|
+
return { file, stat }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (file) {
|
|
150
|
+
if (stat) await releaseLock(path, { file, stat })
|
|
151
|
+
else await file.close().catch(() => {})
|
|
152
|
+
}
|
|
153
|
+
if (error.code !== 'EEXIST') throw error
|
|
154
|
+
try {
|
|
155
|
+
assertOwned(await lstat(path), 'file')
|
|
156
|
+
} catch (inspectionError) {
|
|
157
|
+
if (inspectionError.code === 'ENOENT') continue
|
|
158
|
+
throw inspectionError
|
|
159
|
+
}
|
|
160
|
+
if (performance.now() >= deadline) throw storeError('SCOPE_STORE_CONFLICT')
|
|
161
|
+
// Never infer staleness or remove another process's lock.
|
|
162
|
+
await delay(20)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function releaseLock(path, lock) {
|
|
168
|
+
try {
|
|
169
|
+
const current = await lstat(path)
|
|
170
|
+
if (!sameFile(current, lock.stat) || !current.isFile()) throw storeError()
|
|
171
|
+
await unlink(path)
|
|
172
|
+
} finally {
|
|
173
|
+
await lock.file.close()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function syncDirectory(directory) {
|
|
178
|
+
// Windows does not expose fsync on directory handles through Node.
|
|
179
|
+
if (process.platform === 'win32') return
|
|
180
|
+
const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
181
|
+
try {
|
|
182
|
+
assertOwned(await handle.stat(), 'directory')
|
|
183
|
+
await handle.sync()
|
|
184
|
+
} finally {
|
|
185
|
+
await handle.close()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Durable non-secret scope sidecars. No user directory is touched until load/save. */
|
|
190
|
+
export function createFileSessionScopeStore({ directory } = {}) {
|
|
191
|
+
if (directory !== undefined && (typeof directory !== 'string' || !directory.trim())) throw storeError()
|
|
192
|
+
const root = directory === undefined ? defaultDirectory() : resolve(directory)
|
|
193
|
+
return {
|
|
194
|
+
async load(sessionId) {
|
|
195
|
+
try {
|
|
196
|
+
const key = sessionKey(sessionId)
|
|
197
|
+
if (!await ensureDirectory(root, false)) return null
|
|
198
|
+
return await readRecord(join(root, `${key}.json`), sessionId)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw safeError(error)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
async save(sessionId, record, expectedRevision) {
|
|
204
|
+
let lock
|
|
205
|
+
let temporaryPath
|
|
206
|
+
const key = sessionKey(sessionId)
|
|
207
|
+
const path = join(root, `${key}.json`)
|
|
208
|
+
const lockPath = join(root, `${key}.lock`)
|
|
209
|
+
try {
|
|
210
|
+
const copy = nextRecord(sessionId, record, expectedRevision)
|
|
211
|
+
await ensureDirectory(root, true)
|
|
212
|
+
lock = await acquireLock(lockPath)
|
|
213
|
+
assertRevision(await readRecord(path, sessionId), expectedRevision)
|
|
214
|
+
temporaryPath = join(root, `.${key}.${randomUUID()}.tmp`)
|
|
215
|
+
const temporary = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
216
|
+
try {
|
|
217
|
+
await temporary.chmod(0o600)
|
|
218
|
+
assertOwned(await temporary.stat(), 'file')
|
|
219
|
+
await temporary.writeFile(JSON.stringify(copy), 'utf8')
|
|
220
|
+
await temporary.sync()
|
|
221
|
+
} finally {
|
|
222
|
+
await temporary.close()
|
|
223
|
+
}
|
|
224
|
+
await rename(temporaryPath, path)
|
|
225
|
+
temporaryPath = undefined
|
|
226
|
+
await syncDirectory(root)
|
|
227
|
+
return snapshotRecord(sessionId, copy)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw safeError(error)
|
|
230
|
+
} finally {
|
|
231
|
+
try {
|
|
232
|
+
if (temporaryPath) await unlink(temporaryPath).catch((error) => {
|
|
233
|
+
if (error.code !== 'ENOENT') throw storeError()
|
|
234
|
+
})
|
|
235
|
+
} finally {
|
|
236
|
+
if (lock) {
|
|
237
|
+
try {
|
|
238
|
+
await releaseLock(lockPath, lock)
|
|
239
|
+
} catch {
|
|
240
|
+
throw storeError()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Explicit in-memory adapter: CAS-compatible, but never survives process restart. */
|
|
250
|
+
export function createMemorySessionScopeStore() {
|
|
251
|
+
const records = new Map()
|
|
252
|
+
return {
|
|
253
|
+
async load(sessionId) {
|
|
254
|
+
sessionKey(sessionId)
|
|
255
|
+
const record = records.get(sessionId)
|
|
256
|
+
return record ? snapshotRecord(sessionId, record) : null
|
|
257
|
+
},
|
|
258
|
+
async save(sessionId, record, expectedRevision) {
|
|
259
|
+
const copy = nextRecord(sessionId, record, expectedRevision)
|
|
260
|
+
assertRevision(records.get(sessionId), expectedRevision)
|
|
261
|
+
records.set(sessionId, copy)
|
|
262
|
+
return snapshotRecord(sessionId, copy)
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { createFileSessionScopeStore } from './session-scope-store.js'
|
|
3
|
+
|
|
4
|
+
const SCHEMA = 'bailing.agent-session-scope.v1'
|
|
5
|
+
const KEY = /^conn_[0-9a-f]{32}$/
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
7
|
+
const LOST_AUTHORIZATION_STATES = new Set(['logged_out', 'revoked', 'expired'])
|
|
8
|
+
|
|
9
|
+
function definitiveAuthorizationFailure(error) {
|
|
10
|
+
return ['AUTHORIZATION_CHANGED', 'CROSS_SYSTEM_SCOPE_UNSUPPORTED'].includes(error?.code) ||
|
|
11
|
+
(error?.name === 'AgentAuthHttpError' && [401, 403].includes(error.statusCode))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function scopeError(code = 'SESSION_SCOPE_UNAVAILABLE') {
|
|
15
|
+
const error = new Error(`BailingHub session scope is unavailable (${code}); retry original-scope validation when connectivity returns, or confirm scope before the first message`)
|
|
16
|
+
error.code = code
|
|
17
|
+
return error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Host-owned selection. Neither model arguments nor global defaults can grant scope. */
|
|
21
|
+
export class SessionScopeCoordinator {
|
|
22
|
+
constructor({ store = createFileSessionScopeStore(), getTransport, normalizeConfig, sanitize }) {
|
|
23
|
+
if (typeof store?.load !== 'function' || typeof store?.save !== 'function') throw new TypeError('A session scope store with load/save is required')
|
|
24
|
+
this.store = store
|
|
25
|
+
this.getTransport = getTransport
|
|
26
|
+
this.normalizeConfig = normalizeConfig
|
|
27
|
+
this.sanitize = sanitize
|
|
28
|
+
this.entries = new Map()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
entry(sessionId) {
|
|
32
|
+
if (typeof sessionId !== 'string' || !sessionId.trim() || sessionId.length > 512 || /[\u0000-\u001f\u007f]/.test(sessionId)) {
|
|
33
|
+
throw scopeError('INVALID_SESSION_ID')
|
|
34
|
+
}
|
|
35
|
+
if (!this.entries.has(sessionId)) this.entries.set(sessionId, { sessionId, record: null, loaded: false, claimed: false, blocked: false, retryRequired: false, pending: false, generation: 0 })
|
|
36
|
+
return this.entries.get(sessionId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
validate(record, sessionId) {
|
|
40
|
+
if (!record || record.schema !== SCHEMA || record.sessionId !== sessionId ||
|
|
41
|
+
!Number.isSafeInteger(record.revision) || record.revision < 1 ||
|
|
42
|
+
!['ready', 'needs_selection'].includes(record.state) || typeof record.locked !== 'boolean' ||
|
|
43
|
+
!Array.isArray(record.authorizations) || record.authorizations.length > 64 ||
|
|
44
|
+
Object.keys(record).some((key) => !['schema', 'sessionId', 'revision', 'state', 'locked', 'binding', 'authorizations'].includes(key))) throw scopeError('INVALID_SCOPE_SNAPSHOT')
|
|
45
|
+
const keys = new Set()
|
|
46
|
+
for (const item of record.authorizations) {
|
|
47
|
+
if (!KEY.test(item?.connectionKey ?? '') || keys.has(item.connectionKey) || !UUID.test(item.sessionId ?? '') ||
|
|
48
|
+
typeof item.label !== 'string' || item.label.length > 128 || item.workspace !== record.binding?.workspace ||
|
|
49
|
+
Object.keys(item).some((key) => !['connectionKey', 'sessionId', 'label', 'workspace'].includes(key))) throw scopeError('INVALID_SCOPE_SNAPSHOT')
|
|
50
|
+
keys.add(item.connectionKey)
|
|
51
|
+
}
|
|
52
|
+
if (record.authorizations.length) {
|
|
53
|
+
if (!record.binding || typeof record.binding !== 'object' || Array.isArray(record.binding) ||
|
|
54
|
+
Object.keys(record.binding).some((key) => !['hubUrl', 'clientAppId', 'workspace'].includes(key))) throw scopeError('INVALID_SCOPE_SNAPSHOT')
|
|
55
|
+
const binding = this.normalizeConfig(record.binding)
|
|
56
|
+
if (!binding.valid || binding.hubUrl !== record.binding.hubUrl || binding.clientAppId !== record.binding.clientAppId || binding.workspace !== record.binding.workspace || record.state !== 'ready') throw scopeError('INVALID_SCOPE_SNAPSHOT')
|
|
57
|
+
} else if (record.binding !== null) throw scopeError('INVALID_SCOPE_SNAPSHOT')
|
|
58
|
+
return structuredClone(record)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async load(entry) {
|
|
62
|
+
if (!entry.loaded) {
|
|
63
|
+
entry.loading ??= (async () => {
|
|
64
|
+
try {
|
|
65
|
+
const value = await this.store.load(entry.sessionId)
|
|
66
|
+
entry.record = value === null ? null : this.validate(value, entry.sessionId)
|
|
67
|
+
// An unlocked selection is only a draft. A failed replacement may
|
|
68
|
+
// have left its very first tombstone unwritten, so no stored draft
|
|
69
|
+
// can prove the user's latest intent after this runtime is rebuilt.
|
|
70
|
+
if (entry.record && !entry.record.locked) entry.blocked = true
|
|
71
|
+
// A restored business snapshot is not a current authorization proof.
|
|
72
|
+
// This in-memory fence never rewrites its identities or CAS revision.
|
|
73
|
+
entry.retryRequired = Boolean(entry.record?.authorizations.length)
|
|
74
|
+
} catch {
|
|
75
|
+
entry.blocked = true
|
|
76
|
+
entry.loadFailed = true
|
|
77
|
+
}
|
|
78
|
+
entry.loaded = true
|
|
79
|
+
})()
|
|
80
|
+
await entry.loading
|
|
81
|
+
}
|
|
82
|
+
return entry
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
view(entry) {
|
|
86
|
+
const record = entry.record
|
|
87
|
+
const blocked = entry.blocked || entry.retryRequired || this.isValidating(entry) || entry.pending || record?.state === 'needs_selection'
|
|
88
|
+
const state = blocked ? 'needs_selection' : record ? 'ready' : 'unselected'
|
|
89
|
+
return {
|
|
90
|
+
schema: SCHEMA, sessionId: entry.sessionId, revision: record?.revision ?? null,
|
|
91
|
+
state, locked: entry.claimed || record?.locked === true,
|
|
92
|
+
mode: blocked ? 'blocked' : record?.authorizations.length ? 'business' : 'chat',
|
|
93
|
+
authorizations: blocked ? [] : (record?.authorizations ?? []).map((item) => ({
|
|
94
|
+
authorizationRef: `auth_${createHash('sha256').update(`${entry.sessionId}\0${item.connectionKey}`).digest('hex').slice(0, 24)}`,
|
|
95
|
+
connectionKey: item.connectionKey, label: item.label, workspace: item.workspace,
|
|
96
|
+
})),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async get(sessionId) {
|
|
101
|
+
const entry = await this.load(this.entry(sessionId))
|
|
102
|
+
if (entry.record && !entry.pending && !entry.blocked) {
|
|
103
|
+
await this.checkStored(entry)
|
|
104
|
+
if (!entry.blocked && entry.record.state === 'ready') {
|
|
105
|
+
await this.revalidate(entry)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return this.view(entry)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async write(entry, values) {
|
|
112
|
+
const expected = entry.record?.revision ?? null
|
|
113
|
+
const record = this.validate({ schema: SCHEMA, sessionId: entry.sessionId, revision: (expected ?? 0) + 1, ...values }, entry.sessionId)
|
|
114
|
+
await this.store.save(entry.sessionId, structuredClone(record), expected)
|
|
115
|
+
entry.record = record
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async checkStored(entry) {
|
|
119
|
+
const original = entry.record
|
|
120
|
+
const generation = entry.generation
|
|
121
|
+
try {
|
|
122
|
+
const record = this.validate(await this.store.load(entry.sessionId), entry.sessionId)
|
|
123
|
+
if (JSON.stringify(record) !== JSON.stringify(original)) throw scopeError('SESSION_SCOPE_CONFLICT')
|
|
124
|
+
} catch {
|
|
125
|
+
if (entry.record === original && entry.generation === generation) entry.blocked = true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async revalidate(entry) {
|
|
130
|
+
const original = entry.record
|
|
131
|
+
const generation = entry.generation
|
|
132
|
+
if (!original || entry.blocked || entry.pending) return
|
|
133
|
+
if (this.isValidating(entry)) return entry.validation.promise
|
|
134
|
+
const validation = { record: original, generation }
|
|
135
|
+
entry.validation = validation
|
|
136
|
+
entry.retryRequired = Boolean(original.authorizations.length)
|
|
137
|
+
const current = () => entry.record === original && entry.generation === generation && !entry.blocked && !entry.pending
|
|
138
|
+
validation.promise = (async () => {
|
|
139
|
+
try {
|
|
140
|
+
await this.resolve(original.authorizations.map((item) => item.connectionKey), original)
|
|
141
|
+
if (!current()) return
|
|
142
|
+
// A network wait must not hide a concurrent durable scope change.
|
|
143
|
+
await this.checkStored(entry)
|
|
144
|
+
if (current()) entry.retryRequired = false
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (current()) this.invalidate(entry.sessionId, error)
|
|
147
|
+
} finally {
|
|
148
|
+
if (entry.validation === validation) entry.validation = undefined
|
|
149
|
+
}
|
|
150
|
+
})()
|
|
151
|
+
return validation.promise
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
isValidating(entry) {
|
|
155
|
+
return Boolean(entry.validation && entry.validation.record === entry.record && entry.validation.generation === entry.generation)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
validateStatus(status, { connectionKey, workspace, sessionId }) {
|
|
159
|
+
// An incomplete response proves neither identity nor revocation. A retry
|
|
160
|
+
// may proceed only after the exact original identity is actually verified.
|
|
161
|
+
if (!status || !KEY.test(status.connectionKey ?? '') || typeof status.workspace !== 'string' || !status.workspace) throw scopeError('AUTHORIZATION_UNVERIFIED')
|
|
162
|
+
if (!LOST_AUTHORIZATION_STATES.has(status.state) &&
|
|
163
|
+
(status.state !== 'authorized' || !UUID.test(status.sessionId ?? ''))) throw scopeError('AUTHORIZATION_UNVERIFIED')
|
|
164
|
+
if (status.connectionKey !== connectionKey || status.workspace !== workspace || LOST_AUTHORIZATION_STATES.has(status.state)) throw scopeError('AUTHORIZATION_CHANGED')
|
|
165
|
+
if (sessionId && sessionId !== status.sessionId) throw scopeError('AUTHORIZATION_CHANGED')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async resolve(connectionKeys, original) {
|
|
169
|
+
if (!connectionKeys.length) return { binding: null, authorizations: [] }
|
|
170
|
+
const transport = await this.getTransport()
|
|
171
|
+
const registry = await transport.connectionsList()
|
|
172
|
+
if (!Array.isArray(registry?.connections) || registry.connections.some((item) => !KEY.test(item?.connectionKey ?? ''))) throw scopeError('AUTHORIZATION_UNVERIFIED')
|
|
173
|
+
let binding
|
|
174
|
+
const authorizations = []
|
|
175
|
+
for (const connectionKey of connectionKeys) {
|
|
176
|
+
const matches = registry.connections.filter((item) => item?.connectionKey === connectionKey)
|
|
177
|
+
if (!matches.length) throw scopeError('AUTHORIZATION_CHANGED')
|
|
178
|
+
if (matches.length !== 1) throw scopeError('AUTHORIZATION_UNVERIFIED')
|
|
179
|
+
if (LOST_AUTHORIZATION_STATES.has(matches[0].state)) throw scopeError('AUTHORIZATION_CHANGED')
|
|
180
|
+
if (matches[0].state !== 'authorized') throw scopeError('AUTHORIZATION_UNVERIFIED')
|
|
181
|
+
const selected = this.normalizeConfig(matches[0])
|
|
182
|
+
if (!selected.valid) throw scopeError('AUTHORIZATION_UNVERIFIED')
|
|
183
|
+
const candidate = { hubUrl: selected.hubUrl, clientAppId: selected.clientAppId, workspace: selected.workspace }
|
|
184
|
+
binding ??= candidate
|
|
185
|
+
if (JSON.stringify(binding) !== JSON.stringify(candidate)) throw scopeError('CROSS_SYSTEM_SCOPE_UNSUPPORTED')
|
|
186
|
+
if (original && JSON.stringify(original.binding) !== JSON.stringify(candidate)) throw scopeError('AUTHORIZATION_CHANGED')
|
|
187
|
+
// Only selected keys are probed. Never resolve the SDK's current connection.
|
|
188
|
+
const status = await transport.status({ connectionKey })
|
|
189
|
+
const saved = original?.authorizations.find((item) => item.connectionKey === connectionKey)
|
|
190
|
+
this.validateStatus(status, { connectionKey, workspace: binding.workspace, sessionId: saved?.sessionId })
|
|
191
|
+
const label = String(this.sanitize(matches[0].connectionName ?? 'Authorized account'))
|
|
192
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 128).trim() || 'Authorized account'
|
|
193
|
+
authorizations.push({ connectionKey, sessionId: status.sessionId, label: saved?.label ?? label, workspace: binding.workspace })
|
|
194
|
+
}
|
|
195
|
+
return { binding, authorizations }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async set(sessionId, request) {
|
|
199
|
+
const entry = this.entry(sessionId)
|
|
200
|
+
if (entry.claimed || entry.record?.locked) throw scopeError('SESSION_SCOPE_LOCKED')
|
|
201
|
+
// Host UI state may be mutated immediately after calling this API. Capture
|
|
202
|
+
// the requested selection before the first storage/network await.
|
|
203
|
+
try { request = structuredClone(request) } catch { request = null }
|
|
204
|
+
if (entry.pending) {
|
|
205
|
+
entry.blocked = true
|
|
206
|
+
entry.generation += 1
|
|
207
|
+
throw scopeError('SESSION_SCOPE_CONFLICT')
|
|
208
|
+
}
|
|
209
|
+
// Close the gate synchronously, before storage or authorization validation.
|
|
210
|
+
entry.pending = true
|
|
211
|
+
const generation = ++entry.generation
|
|
212
|
+
try {
|
|
213
|
+
await this.load(entry)
|
|
214
|
+
if (entry.claimed || entry.record?.locked) throw scopeError('SESSION_SCOPE_LOCKED')
|
|
215
|
+
const previousRevision = entry.record?.revision ?? null
|
|
216
|
+
const failedLoad = entry.loadFailed
|
|
217
|
+
entry.blocked = true
|
|
218
|
+
// Invalidate the previous selection durably when storage permits it.
|
|
219
|
+
// load() also requires confirmation of every unlocked stored draft,
|
|
220
|
+
// covering failures before this first write can reach storage.
|
|
221
|
+
await this.write(entry, { state: 'needs_selection', locked: false, binding: null, authorizations: [] })
|
|
222
|
+
if (failedLoad) throw scopeError('SESSION_SCOPE_UNAVAILABLE')
|
|
223
|
+
if (!request || typeof request !== 'object' || Array.isArray(request) ||
|
|
224
|
+
Object.keys(request).some((key) => !['connectionKeys', 'expectedRevision'].includes(key)) ||
|
|
225
|
+
!Array.isArray(request.connectionKeys) || request.connectionKeys.length > 64 ||
|
|
226
|
+
request.connectionKeys.some((key) => typeof key !== 'string' || !KEY.test(key)) ||
|
|
227
|
+
new Set(request.connectionKeys).size !== request.connectionKeys.length) throw scopeError('INVALID_SCOPE_SELECTION')
|
|
228
|
+
if (Object.hasOwn(request, 'expectedRevision') && request.expectedRevision !== previousRevision) throw scopeError('SESSION_SCOPE_CONFLICT')
|
|
229
|
+
const selected = await this.resolve([...request.connectionKeys].sort())
|
|
230
|
+
if (entry.claimed || generation !== entry.generation) throw scopeError('SESSION_SCOPE_CONFLICT')
|
|
231
|
+
await this.write(entry, { state: 'ready', locked: false, ...selected })
|
|
232
|
+
if (entry.claimed || generation !== entry.generation) {
|
|
233
|
+
await this.write(entry, { state: 'needs_selection', locked: entry.claimed, binding: null, authorizations: [] })
|
|
234
|
+
throw scopeError('SESSION_SCOPE_CONFLICT')
|
|
235
|
+
}
|
|
236
|
+
entry.blocked = false
|
|
237
|
+
entry.retryRequired = false
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error?.code !== 'SESSION_SCOPE_LOCKED') entry.blocked = true
|
|
240
|
+
throw scopeError(error?.code === 'SESSION_SCOPE_LOCKED' ? error.code : 'SESSION_SCOPE_SELECTION_FAILED')
|
|
241
|
+
} finally {
|
|
242
|
+
entry.pending = false
|
|
243
|
+
}
|
|
244
|
+
return this.view(entry)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
markStarted(sessionId) {
|
|
248
|
+
const entry = this.entry(sessionId)
|
|
249
|
+
entry.claimed = true
|
|
250
|
+
if (entry.pending) { entry.blocked = true; entry.generation += 1 }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async restore(sessionId, { knownDraft = false } = {}) {
|
|
254
|
+
const entry = await this.load(this.entry(sessionId))
|
|
255
|
+
if (entry.pending) { entry.blocked = true; entry.generation += 1 }
|
|
256
|
+
if (!entry.record) {
|
|
257
|
+
entry.blocked = true
|
|
258
|
+
// Missing scope is not evidence of a user message. Only the trusted host
|
|
259
|
+
// history can distinguish a never-started draft from unknown old history.
|
|
260
|
+
if (!knownDraft) entry.claimed = true
|
|
261
|
+
} else if (!entry.blocked && entry.record.state === 'ready') {
|
|
262
|
+
await this.checkStored(entry)
|
|
263
|
+
if (!entry.blocked) await this.revalidate(entry)
|
|
264
|
+
}
|
|
265
|
+
return this.view(entry)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async begin(sessionId, restored = false) {
|
|
269
|
+
const entry = this.entry(sessionId)
|
|
270
|
+
entry.beginPromise ??= this.beginOnce(sessionId, restored).finally(() => { entry.beginPromise = undefined })
|
|
271
|
+
return entry.beginPromise
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async beginOnce(sessionId, restored = false) {
|
|
275
|
+
const entry = await this.load(this.entry(sessionId))
|
|
276
|
+
// Old history must never acquire a fresh selection before it is reopened.
|
|
277
|
+
if (restored && !entry.record?.locked) entry.blocked = true
|
|
278
|
+
if (entry.pending || entry.blocked || entry.record?.state === 'needs_selection') return this.view(entry)
|
|
279
|
+
if (entry.record) await this.checkStored(entry)
|
|
280
|
+
if (entry.blocked) return this.view(entry)
|
|
281
|
+
try {
|
|
282
|
+
if (!entry.record || !entry.record.locked) {
|
|
283
|
+
await this.write(entry, { state: 'ready', locked: true, binding: entry.record?.binding ?? null, authorizations: entry.record?.authorizations ?? [] })
|
|
284
|
+
}
|
|
285
|
+
} catch { entry.blocked = true }
|
|
286
|
+
// Storage failures remain terminal. Unverified authorizations instead keep
|
|
287
|
+
// a closed, retryable gate before any selected system receives user input.
|
|
288
|
+
if (!entry.blocked) await this.revalidate(entry)
|
|
289
|
+
return this.view(entry)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
snapshot(sessionId) { return structuredClone(this.entry(sessionId).record) }
|
|
293
|
+
|
|
294
|
+
async assertUsable(sessionId) {
|
|
295
|
+
const entry = await this.load(this.entry(sessionId))
|
|
296
|
+
while (this.isValidating(entry)) await entry.validation.promise
|
|
297
|
+
this.assertCurrent(sessionId)
|
|
298
|
+
await this.checkStored(entry)
|
|
299
|
+
while (this.isValidating(entry)) await entry.validation.promise
|
|
300
|
+
this.assertCurrent(sessionId)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Last synchronous dispatch gate: a scope invalidated while an SDK/status
|
|
304
|
+
// promise was pending cannot send input or a write after that promise resolves.
|
|
305
|
+
assertCurrent(sessionId) {
|
|
306
|
+
const entry = this.entry(sessionId)
|
|
307
|
+
if (entry.pending || entry.blocked || entry.retryRequired || this.isValidating(entry) || entry.record?.state !== 'ready' || !entry.record.locked || !entry.record.authorizations.length) throw scopeError()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Only authorization probes pass an error here. Unknown transport failures
|
|
311
|
+
// are not revocations; they close dispatch until a fresh whole-scope proof.
|
|
312
|
+
// A newer invalidation always supersedes any in-flight validation success.
|
|
313
|
+
invalidate(sessionId, error) {
|
|
314
|
+
const entry = this.entry(sessionId)
|
|
315
|
+
entry.generation += 1
|
|
316
|
+
if (!error || definitiveAuthorizationFailure(error)) entry.blocked = true
|
|
317
|
+
else entry.retryRequired = true
|
|
318
|
+
}
|
|
319
|
+
}
|
package/lib/transport.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
const DEFAULT_SDK_MODULE = 'bailinghub-mcp-server/sdk'
|
|
2
2
|
|
|
3
3
|
const REQUIRED_METHODS = [
|
|
4
|
+
'connectionsList',
|
|
5
|
+
'connectionsAdd',
|
|
6
|
+
'connectionsUse',
|
|
7
|
+
'connectionsRemove',
|
|
4
8
|
'login',
|
|
5
9
|
'status',
|
|
6
10
|
'logout',
|
|
@@ -45,7 +49,7 @@ function resolveSdkFactory(module) {
|
|
|
45
49
|
* Lazily resolves the generic BailingHub Agent Client SDK. The SDK, rather than
|
|
46
50
|
* this DSH adapter, owns browser authorization, token storage, refresh, and the
|
|
47
51
|
* HTTP endpoint mapping. A missing SDK therefore degrades only the first use;
|
|
48
|
-
* it never prevents Cordis from loading the plugin
|
|
52
|
+
* it never prevents Cordis from loading the plugin.
|
|
49
53
|
*/
|
|
50
54
|
export function createLazySdkTransport(config, options = {}) {
|
|
51
55
|
const importModule = options.importModule ?? ((specifier) => import(specifier))
|
|
@@ -68,12 +72,22 @@ export function createLazySdkTransport(config, options = {}) {
|
|
|
68
72
|
return clientPromise
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
const transport = Object.fromEntries(
|
|
72
76
|
REQUIRED_METHODS.map((method) => [
|
|
73
77
|
method,
|
|
74
78
|
async (...args) => (await client())[method](...args),
|
|
75
79
|
]),
|
|
76
80
|
)
|
|
81
|
+
// Archive support is optional: an older SDK must retain all business methods.
|
|
82
|
+
transport.supportsConversationArchive = async () => typeof (await client()).syncConversationArchive === 'function'
|
|
83
|
+
transport.syncConversationArchive = async (...args) => {
|
|
84
|
+
const sdk = await client()
|
|
85
|
+
if (typeof sdk.syncConversationArchive !== 'function') {
|
|
86
|
+
throw Object.assign(new Error('The installed SDK does not support conversation archives'), { code: 'ARCHIVE_UNSUPPORTED' })
|
|
87
|
+
}
|
|
88
|
+
return sdk.syncConversationArchive(...args)
|
|
89
|
+
}
|
|
90
|
+
return transport
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
export function createTransportProvider(config, options = {}) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bailinghub",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Let a local DeepSeek Harness Agent use BailingHub to query data, update records, and run permitted actions in your online store, SaaS, or other admin system.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"cordis.agent-client.patch.yml",
|
|
40
40
|
"cordis.patch.yml",
|
|
41
41
|
"docs/AGENT_CLIENT_CONTRACT.md",
|
|
42
|
+
"docs/GETTING_STARTED.md",
|
|
43
|
+
"docs/GETTING_STARTED.zh-CN.md",
|
|
42
44
|
"docs/MIGRATION_VNEXT.md",
|
|
43
45
|
"docs/README.zh-CN.md",
|
|
44
46
|
"SECURITY.md",
|
|
@@ -52,14 +54,22 @@
|
|
|
52
54
|
},
|
|
53
55
|
"scripts": {
|
|
54
56
|
"check:project": "node scripts/check-project.mjs",
|
|
57
|
+
"check:marketplace": "node scripts/check-marketplace.mjs",
|
|
55
58
|
"check:release-tag": "node scripts/check-release-tag.mjs",
|
|
56
59
|
"test": "node --test tests/*.test.mjs",
|
|
57
|
-
"verify": "npm run check:project && npm test",
|
|
60
|
+
"verify": "npm run check:project && npm run check:marketplace && npm test",
|
|
58
61
|
"prepack": "npm run verify"
|
|
59
62
|
},
|
|
60
63
|
"dependencies": {
|
|
61
64
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
62
|
-
"bailinghub-mcp-server": "0.
|
|
65
|
+
"bailinghub-mcp-server": "0.4.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
69
|
+
"@deepseek-ai/dsh-commands": "0.1.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-scope": "0.1.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-tools": "0.1.1-rc.2"
|
|
63
73
|
},
|
|
64
74
|
"dsh": {
|
|
65
75
|
"bundle": {
|