dsh-scnet 0.6.6 → 0.6.7
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 +7 -9
- package/README_CN.md +5 -6
- package/connector/contract.mjs +96 -0
- package/connector/index.mjs +21 -0
- package/connector/oauth2.mjs +263 -0
- package/index.mjs +34 -14
- package/package.json +5 -1
- package/skills/scnet-hpc/VERSION +1 -1
- package/skills/scnet-hpc/references/environment.md +14 -0
- package/skills/scnet-hpc/scripts/_common.sh +31 -0
- package/skills/scnet-hpc/scripts/new-job.sh +1 -0
- package/skills/scnet-hpc/scripts/setup.sh +5 -5
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.
|
|
7
|
+
Current release: **0.6.7**
|
|
8
8
|
|
|
9
9
|
<!-- scnet-release:start -->
|
|
10
|
-
## 0.6.
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
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.7 highlights
|
|
11
|
+
|
|
12
|
+
- Desktop hosts can inject a verified Python 3 interpreter through `DSH_SCNET_PYTHON` or
|
|
13
|
+
`SCNET_PYTHON`; local entrypoints no longer require the command name `python3`.
|
|
14
|
+
- Shared shell helpers reject Python 2 and invalid configured interpreters before running
|
|
15
|
+
OpenAPI or local job-generation code.
|
|
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.
|
|
7
|
+
当前版本:**0.6.7**
|
|
8
8
|
|
|
9
9
|
<!-- scnet-release:start -->
|
|
10
|
-
## 0.6.
|
|
10
|
+
## 0.6.7 更新
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
- DSH 分发仓库在同步版本进入 `main` 后,自动完成 npm 发布、Git tag 和 GitHub Release。
|
|
12
|
+
- Desktop 主机可通过 `DSH_SCNET_PYTHON` 或 `SCNET_PYTHON` 注入经过校验的 Python 3
|
|
13
|
+
解释器,本地入口不再强制要求命令名为 `python3`。
|
|
14
|
+
- 共享 shell 辅助函数会在运行 OpenAPI 或本地作业生成前拒绝 Python 2 和无效解释器路径。
|
|
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,21 @@
|
|
|
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
|
+
MemoryCredentialStore,
|
|
13
|
+
TokenLifecycle,
|
|
14
|
+
createAuthorizationRequest,
|
|
15
|
+
createCodeVerifier,
|
|
16
|
+
createPkceChallenge,
|
|
17
|
+
createRefreshTokenRequest,
|
|
18
|
+
createTokenExchangeRequest,
|
|
19
|
+
normalizeTokenResponse,
|
|
20
|
+
validateAuthorizationCallback,
|
|
21
|
+
} from './oauth2.mjs'
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost'])
|
|
4
|
+
|
|
5
|
+
function requiredString(value, label) {
|
|
6
|
+
const normalized = String(value ?? '').trim()
|
|
7
|
+
if (!normalized) throw new TypeError(`${label} must be a non-empty string`)
|
|
8
|
+
return normalized
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseUrl(value, label) {
|
|
12
|
+
let parsed
|
|
13
|
+
try {
|
|
14
|
+
parsed = new URL(requiredString(value, label))
|
|
15
|
+
} catch {
|
|
16
|
+
throw new TypeError(`${label} must be a valid URL`)
|
|
17
|
+
}
|
|
18
|
+
return parsed
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertHttpsEndpoint(value, label) {
|
|
22
|
+
const parsed = parseUrl(value, label)
|
|
23
|
+
if (parsed.protocol !== 'https:') {
|
|
24
|
+
throw new TypeError(`${label} must use https`)
|
|
25
|
+
}
|
|
26
|
+
return parsed
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertRedirectUri(value) {
|
|
30
|
+
const parsed = parseUrl(value, 'redirectUri')
|
|
31
|
+
const isLoopback = parsed.protocol === 'http:' && LOOPBACK_HOSTS.has(parsed.hostname)
|
|
32
|
+
if (parsed.protocol !== 'https:' && !isLoopback) {
|
|
33
|
+
throw new TypeError('redirectUri must use https or a loopback http address')
|
|
34
|
+
}
|
|
35
|
+
return parsed
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function base64url(value) {
|
|
39
|
+
return Buffer.from(value).toString('base64url')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createCodeVerifier() {
|
|
43
|
+
return base64url(randomBytes(32))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createPkceChallenge(codeVerifier) {
|
|
47
|
+
const verifier = requiredString(codeVerifier, 'codeVerifier')
|
|
48
|
+
return base64url(createHash('sha256').update(verifier).digest())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createAuthorizationRequest({
|
|
52
|
+
authorizationEndpoint,
|
|
53
|
+
clientId,
|
|
54
|
+
redirectUri,
|
|
55
|
+
issuer,
|
|
56
|
+
scope = [],
|
|
57
|
+
state = randomUUID(),
|
|
58
|
+
nonce = randomUUID(),
|
|
59
|
+
codeVerifier = createCodeVerifier(),
|
|
60
|
+
} = {}) {
|
|
61
|
+
const endpoint = assertHttpsEndpoint(authorizationEndpoint, 'authorizationEndpoint')
|
|
62
|
+
const redirect = assertRedirectUri(redirectUri)
|
|
63
|
+
const client = requiredString(clientId, 'clientId')
|
|
64
|
+
const normalizedIssuer = issuer === undefined ? undefined : assertHttpsEndpoint(issuer, 'issuer').origin
|
|
65
|
+
const scopes = Array.isArray(scope) ? scope : String(scope).split(/\s+/)
|
|
66
|
+
const normalizedScopes = [...new Set(scopes.map((item) => String(item).trim()).filter(Boolean))]
|
|
67
|
+
if (normalizedScopes.length === 0) throw new TypeError('scope must contain at least one value')
|
|
68
|
+
|
|
69
|
+
const params = new URLSearchParams({
|
|
70
|
+
client_id: client,
|
|
71
|
+
redirect_uri: redirect.href,
|
|
72
|
+
response_type: 'code',
|
|
73
|
+
scope: normalizedScopes.join(' '),
|
|
74
|
+
code_challenge: createPkceChallenge(codeVerifier),
|
|
75
|
+
code_challenge_method: 'S256',
|
|
76
|
+
state: requiredString(state, 'state'),
|
|
77
|
+
nonce: requiredString(nonce, 'nonce'),
|
|
78
|
+
})
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
url: `${endpoint.href}${endpoint.search ? '&' : '?'}${params}`,
|
|
81
|
+
authorizationEndpoint: endpoint.href,
|
|
82
|
+
clientId: client,
|
|
83
|
+
redirectUri: redirect.href,
|
|
84
|
+
issuer: normalizedIssuer,
|
|
85
|
+
scope: Object.freeze(normalizedScopes),
|
|
86
|
+
state,
|
|
87
|
+
nonce,
|
|
88
|
+
codeVerifier,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertExactRedirect(callbackUrl, redirectUri) {
|
|
93
|
+
const actual = parseUrl(callbackUrl, 'callbackUrl')
|
|
94
|
+
const expected = assertRedirectUri(redirectUri)
|
|
95
|
+
if (actual.origin !== expected.origin || actual.pathname !== expected.pathname) {
|
|
96
|
+
throw new Error('OAuth callback redirect URI does not match the request')
|
|
97
|
+
}
|
|
98
|
+
if (actual.port !== expected.port || actual.hash !== expected.hash) {
|
|
99
|
+
throw new Error('OAuth callback redirect URI does not match the request')
|
|
100
|
+
}
|
|
101
|
+
for (const [key, value] of expected.searchParams) {
|
|
102
|
+
if (actual.searchParams.get(key) !== value) {
|
|
103
|
+
throw new Error('OAuth callback redirect URI does not match the request')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return actual
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function validateAuthorizationCallback(callbackUrl, request) {
|
|
110
|
+
const callback = assertExactRedirect(callbackUrl, request.redirectUri)
|
|
111
|
+
const returnedState = callback.searchParams.get('state')
|
|
112
|
+
if (returnedState !== request.state) throw new Error('OAuth state mismatch')
|
|
113
|
+
|
|
114
|
+
const error = callback.searchParams.get('error')
|
|
115
|
+
if (error) {
|
|
116
|
+
const description = callback.searchParams.get('error_description')
|
|
117
|
+
throw new Error(`OAuth authorization failed: ${error}${description ? ` (${description})` : ''}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const code = callback.searchParams.get('code')
|
|
121
|
+
if (!code) throw new Error('OAuth callback did not contain an authorization code')
|
|
122
|
+
if (request.issuer !== undefined) {
|
|
123
|
+
const returnedIssuer = callback.searchParams.get('iss')
|
|
124
|
+
if (returnedIssuer !== request.issuer) throw new Error('OAuth issuer mismatch')
|
|
125
|
+
}
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
code,
|
|
128
|
+
state: returnedState,
|
|
129
|
+
issuer: callback.searchParams.get('iss') || undefined,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createTokenExchangeRequest({
|
|
134
|
+
tokenEndpoint,
|
|
135
|
+
clientId,
|
|
136
|
+
code,
|
|
137
|
+
redirectUri,
|
|
138
|
+
codeVerifier,
|
|
139
|
+
}) {
|
|
140
|
+
const endpoint = assertHttpsEndpoint(tokenEndpoint, 'tokenEndpoint')
|
|
141
|
+
const redirect = assertRedirectUri(redirectUri)
|
|
142
|
+
const params = new URLSearchParams({
|
|
143
|
+
grant_type: 'authorization_code',
|
|
144
|
+
client_id: requiredString(clientId, 'clientId'),
|
|
145
|
+
code: requiredString(code, 'code'),
|
|
146
|
+
redirect_uri: redirect.href,
|
|
147
|
+
code_verifier: requiredString(codeVerifier, 'codeVerifier'),
|
|
148
|
+
})
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
method: 'POST',
|
|
151
|
+
url: endpoint.href,
|
|
152
|
+
headers: Object.freeze({ 'content-type': 'application/x-www-form-urlencoded' }),
|
|
153
|
+
body: params.toString(),
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function createRefreshTokenRequest({ tokenEndpoint, clientId, refreshToken, scope } = {}) {
|
|
158
|
+
const endpoint = assertHttpsEndpoint(tokenEndpoint, 'tokenEndpoint')
|
|
159
|
+
const params = new URLSearchParams({
|
|
160
|
+
grant_type: 'refresh_token',
|
|
161
|
+
client_id: requiredString(clientId, 'clientId'),
|
|
162
|
+
refresh_token: requiredString(refreshToken, 'refreshToken'),
|
|
163
|
+
})
|
|
164
|
+
if (scope !== undefined) params.set('scope', requiredString(scope, 'scope'))
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
method: 'POST',
|
|
167
|
+
url: endpoint.href,
|
|
168
|
+
headers: Object.freeze({ 'content-type': 'application/x-www-form-urlencoded' }),
|
|
169
|
+
body: params.toString(),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function normalizeTokenResponse(response, now = Date.now()) {
|
|
174
|
+
if (!response || typeof response !== 'object') throw new TypeError('token response must be an object')
|
|
175
|
+
const accessToken = requiredString(response.access_token, 'access_token')
|
|
176
|
+
const refreshToken = response.refresh_token === undefined ? undefined : requiredString(response.refresh_token, 'refresh_token')
|
|
177
|
+
const tokenType = String(response.token_type || 'Bearer')
|
|
178
|
+
const expiresIn = Number(response.expires_in)
|
|
179
|
+
if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw new TypeError('expires_in must be a positive number')
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
accessToken,
|
|
182
|
+
refreshToken,
|
|
183
|
+
tokenType,
|
|
184
|
+
scope: response.scope ? String(response.scope) : undefined,
|
|
185
|
+
expiresAt: now + expiresIn * 1000,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export class MemoryCredentialStore {
|
|
190
|
+
#values = new Map()
|
|
191
|
+
|
|
192
|
+
async get(key) {
|
|
193
|
+
return this.#values.get(requiredString(key, 'credential key'))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async set(key, value) {
|
|
197
|
+
const normalizedKey = requiredString(key, 'credential key')
|
|
198
|
+
if (value === undefined) throw new TypeError('credential value must not be undefined')
|
|
199
|
+
this.#values.set(normalizedKey, structuredClone(value))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async delete(key) {
|
|
203
|
+
this.#values.delete(requiredString(key, 'credential key'))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async clear() {
|
|
207
|
+
this.#values.clear()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export class TokenLifecycle {
|
|
212
|
+
#store
|
|
213
|
+
#key
|
|
214
|
+
#now
|
|
215
|
+
#refreshPromise
|
|
216
|
+
|
|
217
|
+
constructor({ store = new MemoryCredentialStore(), key = 'scnet.tokens', now = () => Date.now() } = {}) {
|
|
218
|
+
this.#store = store
|
|
219
|
+
this.#key = requiredString(key, 'token key')
|
|
220
|
+
this.#now = now
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async save(tokenResponse) {
|
|
224
|
+
const token = normalizeTokenResponse(tokenResponse, this.#now())
|
|
225
|
+
await this.#store.set(this.#key, token)
|
|
226
|
+
return this.status()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async status() {
|
|
230
|
+
const token = await this.#store.get(this.#key)
|
|
231
|
+
if (!token) return { authenticated: false, expiresAt: undefined }
|
|
232
|
+
return {
|
|
233
|
+
authenticated: true,
|
|
234
|
+
expiresAt: token.expiresAt,
|
|
235
|
+
hasRefreshToken: Boolean(token.refreshToken),
|
|
236
|
+
scope: token.scope,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async accessToken({ refresh } = {}) {
|
|
241
|
+
const token = await this.#store.get(this.#key)
|
|
242
|
+
if (!token) throw new Error('SCNet authentication required')
|
|
243
|
+
if (token.expiresAt > this.#now() + 30000) return token.accessToken
|
|
244
|
+
if (typeof refresh !== 'function' || !token.refreshToken) {
|
|
245
|
+
throw new Error('SCNet access token expired; re-authentication required')
|
|
246
|
+
}
|
|
247
|
+
if (!this.#refreshPromise) {
|
|
248
|
+
this.#refreshPromise = Promise.resolve()
|
|
249
|
+
.then(() => refresh(token.refreshToken))
|
|
250
|
+
.then((response) => this.save(response))
|
|
251
|
+
.finally(() => {
|
|
252
|
+
this.#refreshPromise = undefined
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
await this.#refreshPromise
|
|
256
|
+
const refreshed = await this.#store.get(this.#key)
|
|
257
|
+
return refreshed.accessToken
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async clear() {
|
|
261
|
+
await this.#store.delete(this.#key)
|
|
262
|
+
}
|
|
263
|
+
}
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
3
|
+
"version": "0.6.7",
|
|
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",
|
package/skills/scnet-hpc/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.6.
|
|
1
|
+
0.6.7
|
|
@@ -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
|
}
|
|
@@ -370,8 +370,8 @@ if [ -z "$ACTION" ]; then
|
|
|
370
370
|
fi
|
|
371
371
|
|
|
372
372
|
if [ "$ACTION" = status ] || [ "$ACTION" = reset ]; then
|
|
373
|
-
if
|
|
374
|
-
exec
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
462
|
+
"请设置 DSH_SCNET_PYTHON 或 SCNET_PYTHON 指向 Python 3,或安装 python3。" >&2
|
|
463
463
|
fi
|
|
464
464
|
fi
|
|
465
465
|
|