dsh-scnet 0.6.6 → 0.6.8

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/README.md CHANGED
@@ -4,17 +4,15 @@
4
4
 
5
5
  DSH-SCNet (`dsh-scnet` on npm) is a community-maintained DSH bundle for operating Supercomputing Network (SCNet). It packages the source [`scnet-hpc`](https://github.com/lql341/scnet-hpc) Skill, profile-aware utilities, and bounded tools for SSH, Slurm, OpenAPI files, regional resources, and Notebook discovery.
6
6
 
7
- Current release: **0.6.6**
7
+ Current release: **0.6.8**
8
8
 
9
9
  <!-- scnet-release:start -->
10
- ## 0.6.6 highlights
11
-
12
- - README release highlights now come from one canonical source and replace the previous release
13
- block instead of accumulating a version-by-version timeline.
14
- - The canonical Skill and Codex Plugin now create Git tags and GitHub Releases automatically after
15
- validation.
16
- - The DSH distribution now publishes npm, its Git tag, and its GitHub Release automatically after
17
- a synchronized version reaches `main`.
10
+ ## 0.6.8 highlights
11
+
12
+ - The connector now exposes stable error codes and JSON-safe envelopes for authentication,
13
+ callback, network, scope, region, and platform failures.
14
+ - OAuth callback sessions are one-shot and reject replay; platform keyring adapters have no
15
+ plaintext fallback.
18
16
  <!-- scnet-release:end -->
19
17
 
20
18
  > This is an independent community project. It is compatible with DeepSeek Harness but is not an official DeepSeek product and does not imply endorsement, partnership, or authorization by DeepSeek.
package/README_CN.md CHANGED
@@ -4,15 +4,14 @@
4
4
 
5
5
  <p align="center">在 DeepSeek Harness 里操作超算互联网(SCNet):配置 SSH、管理 Slurm 作业、查询 OpenAPI 区域和文件、检查 Notebook 资源与实例,并保留海光 DCU/DTK 诊断能力。</p>
6
6
 
7
- 当前版本:**0.6.6**
7
+ 当前版本:**0.6.8**
8
8
 
9
9
  <!-- scnet-release:start -->
10
- ## 0.6.6 更新
10
+ ## 0.6.8 更新
11
11
 
12
- - README 的版本亮点改为由 canonical 单一来源生成;每次发布替换当前版本区块,不再逐版
13
- 堆叠成流水账。
14
- - canonical Skill 和 Codex Plugin 在校验通过后自动创建 Git tag 与 GitHub Release。
15
- - DSH 分发仓库在同步版本进入 `main` 后,自动完成 npm 发布、Git tag 和 GitHub Release。
12
+ - connector 现在提供稳定错误码和 JSON 安全错误信封,覆盖认证、回调、网络、权限、
13
+ 区域和平台能力失败。
14
+ - OAuth 回调会话只允许消费一次并拒绝重放;平台 keyring 适配器不提供明文回退。
16
15
  <!-- scnet-release:end -->
17
16
 
18
17
  > 本项目是独立维护的社区项目,与 DeepSeek Harness 兼容,但不是 DeepSeek 官方产品,也不表示 DeepSeek 对本项目提供背书、合作或授权。
@@ -0,0 +1,96 @@
1
+ const OPERATION_DEFINITIONS = {
2
+ login: { scope: 'openid', mutatesRemote: false, supportsDryRun: false },
3
+ status: { scope: 'scnet.read', mutatesRemote: false, supportsDryRun: false },
4
+ refresh: { scope: 'openid', mutatesRemote: false, supportsDryRun: false },
5
+ logout: { scope: 'openid', mutatesRemote: true, supportsDryRun: false },
6
+ linkAccount: { scope: 'account.link', mutatesRemote: true, supportsDryRun: true },
7
+ unlinkAccount: { scope: 'account.link', mutatesRemote: true, supportsDryRun: true },
8
+ listRegions: { scope: 'scnet.read', mutatesRemote: false, supportsDryRun: false },
9
+ listSchedulers: { scope: 'scnet.read', mutatesRemote: false, supportsDryRun: false },
10
+ listQueues: { scope: 'scnet.read', mutatesRemote: false, supportsDryRun: false },
11
+ submitJob: { scope: 'jobs.submit', mutatesRemote: true, supportsDryRun: true },
12
+ getJob: { scope: 'jobs.read', mutatesRemote: false, supportsDryRun: false },
13
+ cancelJob: { scope: 'jobs.cancel', mutatesRemote: true, supportsDryRun: true },
14
+ listFiles: { scope: 'files.read', mutatesRemote: false, supportsDryRun: false },
15
+ transferFile: { scope: 'files.write', mutatesRemote: true, supportsDryRun: true },
16
+ }
17
+
18
+ export const SCNET_CONNECTOR_CONTRACT_VERSION = '0.1.0'
19
+ export const CONNECTOR_OPERATION_DEFINITIONS = Object.freeze(
20
+ Object.fromEntries(
21
+ Object.entries(OPERATION_DEFINITIONS).map(([name, definition]) => [name, Object.freeze({ ...definition })]),
22
+ ),
23
+ )
24
+ export const CONNECTOR_CAPABILITY_STATES = Object.freeze([
25
+ 'supported',
26
+ 'unsupported',
27
+ 'requires-remote-host',
28
+ ])
29
+
30
+ function nonEmptyString(value, label) {
31
+ const normalized = String(value ?? '').trim()
32
+ if (!normalized) throw new TypeError(`${label} must be a non-empty string`)
33
+ return normalized
34
+ }
35
+
36
+ function normalizeState(value, operation) {
37
+ const state = nonEmptyString(value, `capability ${operation}`)
38
+ if (!CONNECTOR_CAPABILITY_STATES.includes(state)) {
39
+ throw new TypeError(
40
+ `capability ${operation} must be one of ${CONNECTOR_CAPABILITY_STATES.join(', ')}`,
41
+ )
42
+ }
43
+ return state
44
+ }
45
+
46
+ export function createCapabilityMatrix(entries = {}) {
47
+ if (entries === null || typeof entries !== 'object' || Array.isArray(entries)) {
48
+ throw new TypeError('capabilities must be an object')
49
+ }
50
+ return Object.freeze(
51
+ Object.fromEntries(
52
+ Object.keys(OPERATION_DEFINITIONS).map((operation) => [
53
+ operation,
54
+ normalizeState(entries[operation] ?? 'unsupported', operation),
55
+ ]),
56
+ ),
57
+ )
58
+ }
59
+
60
+ export function createConnectorDescriptor({
61
+ id = 'scnet',
62
+ version = SCNET_CONNECTOR_CONTRACT_VERSION,
63
+ issuer,
64
+ capabilities = {},
65
+ } = {}) {
66
+ const descriptor = {
67
+ contractVersion: SCNET_CONNECTOR_CONTRACT_VERSION,
68
+ id: nonEmptyString(id, 'connector id'),
69
+ version: nonEmptyString(version, 'connector version'),
70
+ issuer: nonEmptyString(issuer, 'issuer'),
71
+ capabilities: createCapabilityMatrix(capabilities),
72
+ }
73
+ return Object.freeze(descriptor)
74
+ }
75
+
76
+ export function getOperationDefinition(operation) {
77
+ const name = nonEmptyString(operation, 'operation')
78
+ const definition = CONNECTOR_OPERATION_DEFINITIONS[name]
79
+ if (!definition) throw new RangeError(`unsupported connector operation: ${name}`)
80
+ return definition
81
+ }
82
+
83
+ export function assertCapability(descriptor, operation, { allowRemoteHost = false } = {}) {
84
+ if (!descriptor || typeof descriptor !== 'object') {
85
+ throw new TypeError('connector descriptor is required')
86
+ }
87
+ const definition = getOperationDefinition(operation)
88
+ const state = descriptor.capabilities?.[operation]
89
+ if (state === 'supported' || (state === 'requires-remote-host' && allowRemoteHost)) {
90
+ return definition
91
+ }
92
+ if (state === 'requires-remote-host') {
93
+ throw new Error(`${operation} requires a remote host`)
94
+ }
95
+ throw new Error(`${operation} is not supported by connector ${descriptor.id || 'unknown'}`)
96
+ }
@@ -0,0 +1,42 @@
1
+ function requiredString(value, label) {
2
+ const normalized = String(value ?? '').trim()
3
+ if (!normalized) throw new TypeError(`${label} must be a non-empty string`)
4
+ return normalized
5
+ }
6
+
7
+ function assertBackend(backend) {
8
+ if (!backend || typeof backend !== 'object') {
9
+ throw new TypeError('credential backend is required')
10
+ }
11
+ for (const method of ['get', 'set', 'delete']) {
12
+ if (typeof backend[method] !== 'function') {
13
+ throw new TypeError(`credential backend must implement ${method}()`)
14
+ }
15
+ }
16
+ return backend
17
+ }
18
+
19
+ /**
20
+ * Adapt a platform keyring implementation without providing a plaintext fallback.
21
+ * The backend owns encryption, locking, and OS-specific Secret Service behavior.
22
+ */
23
+ export function createCredentialStoreAdapter({ backend, namespace = 'scnet' } = {}) {
24
+ const store = assertBackend(backend)
25
+ const prefix = requiredString(namespace, 'credential namespace')
26
+ const keyOf = (key) => `${prefix}.${requiredString(key, 'credential key')}`
27
+ return Object.freeze({
28
+ async get(key) {
29
+ return store.get(keyOf(key))
30
+ },
31
+ async set(key, value) {
32
+ if (value === undefined) throw new TypeError('credential value must not be undefined')
33
+ return store.set(keyOf(key), structuredClone(value))
34
+ },
35
+ async delete(key) {
36
+ return store.delete(keyOf(key))
37
+ },
38
+ async clear() {
39
+ if (typeof store.clear === 'function') return store.clear(prefix)
40
+ },
41
+ })
42
+ }
@@ -0,0 +1,58 @@
1
+ const ERROR_CODES = [
2
+ 'AUTHENTICATION_REQUIRED',
3
+ 'AUTHENTICATION_FAILED',
4
+ 'SCOPE_INSUFFICIENT',
5
+ 'NETWORK_TIMEOUT',
6
+ 'REGION_UNAVAILABLE',
7
+ 'PLATFORM_UNSUPPORTED',
8
+ 'CALLBACK_REPLAY',
9
+ 'CALLBACK_MISMATCH',
10
+ 'CONFIGURATION_REQUIRED',
11
+ 'INTERNAL_ERROR',
12
+ ]
13
+
14
+ export const CONNECTOR_ERROR_CODES = Object.freeze(ERROR_CODES)
15
+
16
+ function requiredString(value, label) {
17
+ const normalized = String(value ?? '').trim()
18
+ if (!normalized) throw new TypeError(`${label} must be a non-empty string`)
19
+ return normalized
20
+ }
21
+
22
+ export class ConnectorError extends Error {
23
+ constructor(code, message, { operation, retryable = false } = {}) {
24
+ if (!CONNECTOR_ERROR_CODES.includes(code)) {
25
+ throw new RangeError(`unsupported connector error code: ${code}`)
26
+ }
27
+ super(requiredString(message, 'error message'))
28
+ this.name = 'ConnectorError'
29
+ this.code = code
30
+ this.operation = operation === undefined ? undefined : requiredString(operation, 'operation')
31
+ this.retryable = Boolean(retryable)
32
+ }
33
+
34
+ toJSON() {
35
+ return {
36
+ ok: false,
37
+ error_code: this.code,
38
+ error: this.message,
39
+ ...(this.operation ? { operation: this.operation } : {}),
40
+ retryable: this.retryable,
41
+ }
42
+ }
43
+ }
44
+
45
+ export function connectorError(code, message, options) {
46
+ return new ConnectorError(code, message, options)
47
+ }
48
+
49
+ export function errorEnvelope(error, { operation } = {}) {
50
+ if (error instanceof ConnectorError) {
51
+ return error.toJSON()
52
+ }
53
+ return new ConnectorError(
54
+ 'INTERNAL_ERROR',
55
+ error instanceof Error ? error.message : String(error || 'unknown connector error'),
56
+ { operation },
57
+ ).toJSON()
58
+ }
@@ -0,0 +1,33 @@
1
+ export {
2
+ CONNECTOR_CAPABILITY_STATES,
3
+ CONNECTOR_OPERATION_DEFINITIONS,
4
+ SCNET_CONNECTOR_CONTRACT_VERSION,
5
+ assertCapability,
6
+ createCapabilityMatrix,
7
+ createConnectorDescriptor,
8
+ getOperationDefinition,
9
+ } from './contract.mjs'
10
+
11
+ export {
12
+ CONNECTOR_ERROR_CODES,
13
+ ConnectorError,
14
+ connectorError,
15
+ errorEnvelope,
16
+ } from './errors.mjs'
17
+
18
+ export {
19
+ createCredentialStoreAdapter,
20
+ } from './credentials.mjs'
21
+
22
+ export {
23
+ MemoryCredentialStore,
24
+ TokenLifecycle,
25
+ createAuthorizationRequest,
26
+ createAuthorizationSession,
27
+ createCodeVerifier,
28
+ createPkceChallenge,
29
+ createRefreshTokenRequest,
30
+ createTokenExchangeRequest,
31
+ normalizeTokenResponse,
32
+ validateAuthorizationCallback,
33
+ } from './oauth2.mjs'
@@ -0,0 +1,291 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
2
+ import { ConnectorError } from './errors.mjs'
3
+
4
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost'])
5
+
6
+ function requiredString(value, label) {
7
+ const normalized = String(value ?? '').trim()
8
+ if (!normalized) throw new TypeError(`${label} must be a non-empty string`)
9
+ return normalized
10
+ }
11
+
12
+ function parseUrl(value, label) {
13
+ let parsed
14
+ try {
15
+ parsed = new URL(requiredString(value, label))
16
+ } catch {
17
+ throw new TypeError(`${label} must be a valid URL`)
18
+ }
19
+ return parsed
20
+ }
21
+
22
+ function assertHttpsEndpoint(value, label) {
23
+ const parsed = parseUrl(value, label)
24
+ if (parsed.protocol !== 'https:') {
25
+ throw new TypeError(`${label} must use https`)
26
+ }
27
+ return parsed
28
+ }
29
+
30
+ function assertRedirectUri(value) {
31
+ const parsed = parseUrl(value, 'redirectUri')
32
+ const isLoopback = parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname)
33
+ if (parsed.protocol !== 'https:' && !isLoopback) {
34
+ throw new TypeError('redirectUri must use https or a loopback http address')
35
+ }
36
+ return parsed
37
+ }
38
+
39
+ function base64url(value) {
40
+ return Buffer.from(value).toString('base64url')
41
+ }
42
+
43
+ export function createCodeVerifier() {
44
+ return base64url(randomBytes(32))
45
+ }
46
+
47
+ export function createPkceChallenge(codeVerifier) {
48
+ const verifier = requiredString(codeVerifier, 'codeVerifier')
49
+ return base64url(createHash('sha256').update(verifier).digest())
50
+ }
51
+
52
+ export function createAuthorizationRequest({
53
+ authorizationEndpoint,
54
+ clientId,
55
+ redirectUri,
56
+ issuer,
57
+ scope = [],
58
+ state = randomUUID(),
59
+ nonce = randomUUID(),
60
+ codeVerifier = createCodeVerifier(),
61
+ } = {}) {
62
+ const endpoint = assertHttpsEndpoint(authorizationEndpoint, 'authorizationEndpoint')
63
+ const redirect = assertRedirectUri(redirectUri)
64
+ const client = requiredString(clientId, 'clientId')
65
+ const normalizedIssuer = issuer === undefined ? undefined : assertHttpsEndpoint(issuer, 'issuer').origin
66
+ const scopes = Array.isArray(scope) ? scope : String(scope).split(/\s+/)
67
+ const normalizedScopes = [...new Set(scopes.map((item) => String(item).trim()).filter(Boolean))]
68
+ if (normalizedScopes.length === 0) throw new TypeError('scope must contain at least one value')
69
+
70
+ const params = new URLSearchParams({
71
+ client_id: client,
72
+ redirect_uri: redirect.href,
73
+ response_type: 'code',
74
+ scope: normalizedScopes.join(' '),
75
+ code_challenge: createPkceChallenge(codeVerifier),
76
+ code_challenge_method: 'S256',
77
+ state: requiredString(state, 'state'),
78
+ nonce: requiredString(nonce, 'nonce'),
79
+ })
80
+ return Object.freeze({
81
+ url: `${endpoint.href}${endpoint.search ? '&' : '?'}${params}`,
82
+ authorizationEndpoint: endpoint.href,
83
+ clientId: client,
84
+ redirectUri: redirect.href,
85
+ issuer: normalizedIssuer,
86
+ scope: Object.freeze(normalizedScopes),
87
+ state,
88
+ nonce,
89
+ codeVerifier,
90
+ })
91
+ }
92
+
93
+ function assertExactRedirect(callbackUrl, redirectUri) {
94
+ const actual = parseUrl(callbackUrl, 'callbackUrl')
95
+ const expected = assertRedirectUri(redirectUri)
96
+ if (actual.origin !== expected.origin || actual.pathname !== expected.pathname) {
97
+ throw new Error('OAuth callback redirect URI does not match the request')
98
+ }
99
+ if (actual.port !== expected.port || actual.hash !== expected.hash) {
100
+ throw new Error('OAuth callback redirect URI does not match the request')
101
+ }
102
+ for (const [key, value] of expected.searchParams) {
103
+ if (actual.searchParams.get(key) !== value) {
104
+ throw new Error('OAuth callback redirect URI does not match the request')
105
+ }
106
+ }
107
+ return actual
108
+ }
109
+
110
+ export function validateAuthorizationCallback(callbackUrl, request) {
111
+ const callback = assertExactRedirect(callbackUrl, request.redirectUri)
112
+ const returnedState = callback.searchParams.get('state')
113
+ if (returnedState !== request.state) throw new Error('OAuth state mismatch')
114
+
115
+ const error = callback.searchParams.get('error')
116
+ if (error) {
117
+ const description = callback.searchParams.get('error_description')
118
+ throw new Error(`OAuth authorization failed: ${error}${description ? ` (${description})` : ''}`)
119
+ }
120
+
121
+ const code = callback.searchParams.get('code')
122
+ if (!code) throw new Error('OAuth callback did not contain an authorization code')
123
+ if (request.issuer !== undefined) {
124
+ const returnedIssuer = callback.searchParams.get('iss')
125
+ if (returnedIssuer !== request.issuer) throw new Error('OAuth issuer mismatch')
126
+ }
127
+ return Object.freeze({
128
+ code,
129
+ state: returnedState,
130
+ issuer: callback.searchParams.get('iss') || undefined,
131
+ })
132
+ }
133
+
134
+ export function createAuthorizationSession(options = {}) {
135
+ const request = createAuthorizationRequest(options)
136
+ let consumed = false
137
+ return Object.freeze({
138
+ request,
139
+ get consumed() {
140
+ return consumed
141
+ },
142
+ consumeCallback(callbackUrl) {
143
+ if (consumed) {
144
+ throw new ConnectorError(
145
+ 'CALLBACK_REPLAY',
146
+ 'OAuth authorization callback has already been consumed',
147
+ { operation: 'login' },
148
+ )
149
+ }
150
+ consumed = true
151
+ try {
152
+ return validateAuthorizationCallback(callbackUrl, request)
153
+ } catch (error) {
154
+ if (error instanceof ConnectorError) throw error
155
+ throw new ConnectorError('CALLBACK_MISMATCH', error.message, { operation: 'login' })
156
+ }
157
+ },
158
+ })
159
+ }
160
+
161
+ export function createTokenExchangeRequest({
162
+ tokenEndpoint,
163
+ clientId,
164
+ code,
165
+ redirectUri,
166
+ codeVerifier,
167
+ }) {
168
+ const endpoint = assertHttpsEndpoint(tokenEndpoint, 'tokenEndpoint')
169
+ const redirect = assertRedirectUri(redirectUri)
170
+ const params = new URLSearchParams({
171
+ grant_type: 'authorization_code',
172
+ client_id: requiredString(clientId, 'clientId'),
173
+ code: requiredString(code, 'code'),
174
+ redirect_uri: redirect.href,
175
+ code_verifier: requiredString(codeVerifier, 'codeVerifier'),
176
+ })
177
+ return Object.freeze({
178
+ method: 'POST',
179
+ url: endpoint.href,
180
+ headers: Object.freeze({ 'content-type': 'application/x-www-form-urlencoded' }),
181
+ body: params.toString(),
182
+ })
183
+ }
184
+
185
+ export function createRefreshTokenRequest({ tokenEndpoint, clientId, refreshToken, scope } = {}) {
186
+ const endpoint = assertHttpsEndpoint(tokenEndpoint, 'tokenEndpoint')
187
+ const params = new URLSearchParams({
188
+ grant_type: 'refresh_token',
189
+ client_id: requiredString(clientId, 'clientId'),
190
+ refresh_token: requiredString(refreshToken, 'refreshToken'),
191
+ })
192
+ if (scope !== undefined) params.set('scope', requiredString(scope, 'scope'))
193
+ return Object.freeze({
194
+ method: 'POST',
195
+ url: endpoint.href,
196
+ headers: Object.freeze({ 'content-type': 'application/x-www-form-urlencoded' }),
197
+ body: params.toString(),
198
+ })
199
+ }
200
+
201
+ export function normalizeTokenResponse(response, now = Date.now()) {
202
+ if (!response || typeof response !== 'object') throw new TypeError('token response must be an object')
203
+ const accessToken = requiredString(response.access_token, 'access_token')
204
+ const refreshToken = response.refresh_token === undefined ? undefined : requiredString(response.refresh_token, 'refresh_token')
205
+ const tokenType = String(response.token_type || 'Bearer')
206
+ const expiresIn = Number(response.expires_in)
207
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw new TypeError('expires_in must be a positive number')
208
+ return Object.freeze({
209
+ accessToken,
210
+ refreshToken,
211
+ tokenType,
212
+ scope: response.scope ? String(response.scope) : undefined,
213
+ expiresAt: now + expiresIn * 1000,
214
+ })
215
+ }
216
+
217
+ export class MemoryCredentialStore {
218
+ #values = new Map()
219
+
220
+ async get(key) {
221
+ return this.#values.get(requiredString(key, 'credential key'))
222
+ }
223
+
224
+ async set(key, value) {
225
+ const normalizedKey = requiredString(key, 'credential key')
226
+ if (value === undefined) throw new TypeError('credential value must not be undefined')
227
+ this.#values.set(normalizedKey, structuredClone(value))
228
+ }
229
+
230
+ async delete(key) {
231
+ this.#values.delete(requiredString(key, 'credential key'))
232
+ }
233
+
234
+ async clear() {
235
+ this.#values.clear()
236
+ }
237
+ }
238
+
239
+ export class TokenLifecycle {
240
+ #store
241
+ #key
242
+ #now
243
+ #refreshPromise
244
+
245
+ constructor({ store = new MemoryCredentialStore(), key = 'scnet.tokens', now = () => Date.now() } = {}) {
246
+ this.#store = store
247
+ this.#key = requiredString(key, 'token key')
248
+ this.#now = now
249
+ }
250
+
251
+ async save(tokenResponse) {
252
+ const token = normalizeTokenResponse(tokenResponse, this.#now())
253
+ await this.#store.set(this.#key, token)
254
+ return this.status()
255
+ }
256
+
257
+ async status() {
258
+ const token = await this.#store.get(this.#key)
259
+ if (!token) return { authenticated: false, expiresAt: undefined }
260
+ return {
261
+ authenticated: true,
262
+ expiresAt: token.expiresAt,
263
+ hasRefreshToken: Boolean(token.refreshToken),
264
+ scope: token.scope,
265
+ }
266
+ }
267
+
268
+ async accessToken({ refresh } = {}) {
269
+ const token = await this.#store.get(this.#key)
270
+ if (!token) throw new Error('SCNet authentication required')
271
+ if (token.expiresAt > this.#now() + 30000) return token.accessToken
272
+ if (typeof refresh !== 'function' || !token.refreshToken) {
273
+ throw new Error('SCNet access token expired; re-authentication required')
274
+ }
275
+ if (!this.#refreshPromise) {
276
+ this.#refreshPromise = Promise.resolve()
277
+ .then(() => refresh(token.refreshToken))
278
+ .then((response) => this.save(response))
279
+ .finally(() => {
280
+ this.#refreshPromise = undefined
281
+ })
282
+ }
283
+ await this.#refreshPromise
284
+ const refreshed = await this.#store.get(this.#key)
285
+ return refreshed.accessToken
286
+ }
287
+
288
+ async clear() {
289
+ await this.#store.delete(this.#key)
290
+ }
291
+ }
package/index.mjs CHANGED
@@ -12,6 +12,11 @@ const SKILL_DIR = join(__dirname, 'skills', 'scnet-hpc')
12
12
  const SCRIPTS_DIR = join(SKILL_DIR, 'scripts')
13
13
  const CLUSTERS_DIR = join(SKILL_DIR, 'clusters')
14
14
 
15
+ export function resolvePythonCandidates(env = process.env) {
16
+ const configured = String(env.DSH_SCNET_PYTHON ?? env.SCNET_PYTHON ?? '').trim()
17
+ return configured ? [configured] : ['python3', 'python']
18
+ }
19
+
15
20
  function runBash(script, args, timeoutMs = 120000, cwd = SKILL_DIR) {
16
21
  return new Promise((resolve) => {
17
22
  execFile(
@@ -32,21 +37,35 @@ function runBash(script, args, timeoutMs = 120000, cwd = SKILL_DIR) {
32
37
  }
33
38
 
34
39
  function runScnet(args, timeoutMs = 120000) {
40
+ const candidates = resolvePythonCandidates()
41
+ const scriptArgs = [join(SCRIPTS_DIR, 'scnet.py'), '--json', ...args]
42
+
35
43
  return new Promise((resolve) => {
36
- execFile(
37
- 'python3',
38
- [join(SCRIPTS_DIR, 'scnet.py'), '--json', ...args],
39
- { cwd: SKILL_DIR, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
40
- (error, stdout, stderr) => {
41
- const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0
42
- resolve({
43
- ok: code === 0,
44
- code,
45
- stdout: String(stdout || ''),
46
- stderr: String(stderr || ''),
47
- })
48
- },
49
- )
44
+ const attempt = (index) => {
45
+ const executable = candidates[index]
46
+ execFile(
47
+ executable,
48
+ scriptArgs,
49
+ { cwd: SKILL_DIR, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
50
+ (error, stdout, stderr) => {
51
+ if (error?.code === 'ENOENT' && index + 1 < candidates.length) {
52
+ attempt(index + 1)
53
+ return
54
+ }
55
+ const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0
56
+ resolve({
57
+ ok: code === 0,
58
+ code,
59
+ executable,
60
+ stdout: String(stdout || ''),
61
+ stderr: error?.code === 'ENOENT'
62
+ ? `无法启动 Python 解释器 ${executable};请设置 DSH_SCNET_PYTHON 或 SCNET_PYTHON 指向 Python 3。`
63
+ : String(stderr || ''),
64
+ })
65
+ },
66
+ )
67
+ }
68
+ attempt(0)
50
69
  })
51
70
  }
52
71
 
@@ -1173,5 +1192,6 @@ export const __testables = {
1173
1192
  parseWalltimeSeconds,
1174
1193
  pickBackend,
1175
1194
  queueCandidates,
1195
+ resolvePythonCandidates,
1176
1196
  translateScnetError,
1177
1197
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "dsh-scnet",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Community DeepSeek Harness bundle for SCNet SSH and OpenAPI jobs, files, Notebook resources, Slurm diagnostics, and Hygon DCU/DTK guidance.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": {
8
8
  ".": "./index.mjs",
9
+ "./connector": "./connector/index.mjs",
9
10
  "./cordis.patch.yml": "./cordis.patch.yml",
10
11
  "./package.json": "./package.json"
11
12
  },
@@ -26,8 +27,11 @@
26
27
  },
27
28
  "files": [
28
29
  "index.mjs",
30
+ "connector",
29
31
  "cordis.patch.yml",
30
32
  "skills",
33
+ "!skills/**/__pycache__",
34
+ "!skills/**/*.pyc",
31
35
  "docs",
32
36
  "README_CN.md",
33
37
  "README.md",
@@ -1 +1 @@
1
- 0.6.6
1
+ 0.6.8
@@ -50,6 +50,20 @@ ssh <集群> 'module avail 2>&1'
50
50
 
51
51
  ## Python 环境
52
52
 
53
+ ### 本地脚本解释器
54
+
55
+ Linux Desktop 可将随包 Python 的绝对路径注入本地脚本:
56
+
57
+ ```bash
58
+ export DSH_SCNET_PYTHON=/path/to/bundled/python
59
+ # 通用环境也可使用:
60
+ export SCNET_PYTHON=/path/to/python3
61
+ ```
62
+
63
+ `DSH_SCNET_PYTHON` 优先于 `SCNET_PYTHON`。脚本会验证解释器确实是 Python 3;
64
+ 未注入时才按 `python3`、`python` 顺序探测。该设置只影响本机的 OpenAPI CLI 和作业
65
+ 脚本生成,不会把本机解释器路径传到远端计算节点。
66
+
53
67
  超算通常没有系统级的 torch,用 venv 或 conda env。profile 的 `DEFAULT_VENV`
54
68
  记录默认环境路径(相对家目录)。
55
69
 
@@ -12,6 +12,37 @@ CLUSTER_DIR="$REPO_ROOT/clusters"
12
12
  die() { printf '错误: %s\n' "$1" >&2; exit 1; }
13
13
  info() { printf ' %s\n' "$1"; }
14
14
 
15
+ # Resolve the local Python 3 interpreter without assuming that every desktop
16
+ # distribution exposes the command as `python3`. Desktop hosts may inject an
17
+ # absolute path through DSH_SCNET_PYTHON; SCNET_PYTHON is the portable alias.
18
+ resolve_python() {
19
+ local configured="${DSH_SCNET_PYTHON:-${SCNET_PYTHON:-}}"
20
+ local candidate resolved
21
+
22
+ if [ -n "$configured" ]; then
23
+ if [ -x "$configured" ]; then
24
+ resolved="$configured"
25
+ else
26
+ resolved="$(command -v "$configured" 2>/dev/null || true)"
27
+ fi
28
+ [ -n "$resolved" ] || return 1
29
+ "$resolved" -c 'import sys; raise SystemExit(0 if sys.version_info.major == 3 else 1)' \
30
+ >/dev/null 2>&1 || return 1
31
+ printf '%s\n' "$resolved"
32
+ return 0
33
+ fi
34
+
35
+ for candidate in python3 python; do
36
+ resolved="$(command -v "$candidate" 2>/dev/null || true)"
37
+ [ -n "$resolved" ] || continue
38
+ "$resolved" -c 'import sys; raise SystemExit(0 if sys.version_info.major == 3 else 1)' \
39
+ >/dev/null 2>&1 || continue
40
+ printf '%s\n' "$resolved"
41
+ return 0
42
+ done
43
+ return 1
44
+ }
45
+
15
46
  list_clusters() {
16
47
  find "$CLUSTER_DIR" -maxdepth 1 -name '*.conf' ! -name '_*' -exec basename {} .conf \; 2>/dev/null | sort
17
48
  }
@@ -132,6 +132,7 @@ fi
132
132
 
133
133
  OUT="${NAME}.slurm"
134
134
  [ -e "$OUT" ] && die "$OUT 已存在"
135
+ PYTHON_BIN="$(resolve_python)" || die "本机需要 Python 3;请设置 DSH_SCNET_PYTHON 或 SCNET_PYTHON"
135
136
 
136
137
  cat > "$OUT" <<EOF
137
138
  #!/bin/bash -l
@@ -370,8 +370,8 @@ if [ -z "$ACTION" ]; then
370
370
  fi
371
371
 
372
372
  if [ "$ACTION" = status ] || [ "$ACTION" = reset ]; then
373
- if command -v python3 >/dev/null 2>&1; then
374
- exec python3 "$SCRIPT_DIR/scnet.py" setup "$ACTION"
373
+ if PYTHON_BIN="$(resolve_python)"; then
374
+ exec "$PYTHON_BIN" "$SCRIPT_DIR/scnet.py" setup "$ACTION"
375
375
  fi
376
376
  [ "$ACTION" = status ] || die "reset 需要 Python 3 以执行安全的分范围重置"
377
377
  config_root="${XDG_CONFIG_HOME:-$HOME/.config}/scnet-hpc"
@@ -450,16 +450,16 @@ if [ "$BACKEND" = ssh ] || [ "$BACKEND" = both ]; then
450
450
  fi
451
451
 
452
452
  if [ "$BACKEND" = openapi ] || [ "$BACKEND" = both ]; then
453
- if command -v python3 >/dev/null 2>&1; then
453
+ if PYTHON_BIN="$(resolve_python)"; then
454
454
  printf '%s\n' \
455
455
  "OpenAPI 使用一组平台 AK/SK 自动发现全部授权区域。" >&2
456
456
  openapi_args=(setup "$ACTION" --mode openapi)
457
457
  [ "$SKIP_CONNECT" = yes ] && openapi_args+=(--skip-connect)
458
- python3 "$SCRIPT_DIR/scnet.py" "${openapi_args[@]}"
458
+ "$PYTHON_BIN" "$SCRIPT_DIR/scnet.py" "${openapi_args[@]}"
459
459
  else
460
460
  printf '%s\n' \
461
461
  "OpenAPI backend 需要 Python 3;SSH 配置不受影响。" \
462
- "安装 Python 3 后运行:python3 scripts/scnet.py setup --mode openapi" >&2
462
+ "请设置 DSH_SCNET_PYTHON 或 SCNET_PYTHON 指向 Python 3,或安装 python3。" >&2
463
463
  fi
464
464
  fi
465
465