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.
@@ -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 candidate.
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
- return Object.fromEntries(
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.2.0",
4
- "description": "Native DeepSeek Harness adapter for the BailingHub Agent Client Core.",
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.2.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": {