dsh-scnet 0.6.11 → 0.6.12

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,16 +4,17 @@
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.11**
7
+ Current release: **0.6.12**
8
8
 
9
9
  <!-- scnet-release:start -->
10
- ## 0.6.11 highlights
11
-
12
- - Generate Slurm job scripts even when the optional SSH node-count probe fails, using
13
- profile/cache metadata as a fallback; add deterministic coverage for probe outcomes.
14
- - Correct Windows configuration-permission diagnostics and clarify WSL2 setup guidance.
15
- - Keep shell scripts on LF line endings across all three repositories; synchronize downstream
16
- versions and README release blocks after canonical main validation succeeds.
10
+ ## 0.6.12 highlights
11
+
12
+ - Make first-time AK/SK setup explicit: validate pasted values, hide both keys, show their source,
13
+ and read back saved credentials before confirming persistence.
14
+ - Support Windows Credential Manager and explain macOS Keychain, Linux keyring requirements,
15
+ PowerShell session scope, and the Windows/WSL2 boundary.
16
+ - Preview SSH key installation and effective configuration, document cluster usernames and
17
+ key expiry, and add onboarding tests across Linux, macOS and Windows.
17
18
  <!-- scnet-release:end -->
18
19
 
19
20
  > 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,16 +4,17 @@
4
4
 
5
5
  <p align="center">在 DeepSeek Harness 里操作超算互联网(SCNet):配置 SSH、管理 Slurm 作业、查询 OpenAPI 区域和文件、检查 Notebook 资源与实例,并保留海光 DCU/DTK 诊断能力。</p>
6
6
 
7
- 当前版本:**0.6.11**
7
+ 当前版本:**0.6.12**
8
8
 
9
9
  <!-- scnet-release:start -->
10
- ## 0.6.11 更新
11
-
12
- - SSH 实时节点数探测失败时,回退到 profile/cache,继续生成 Slurm 作业脚本;
13
- 使用模拟 SSH 覆盖各类探测结果,回归测试不依赖真实网络。
14
- - 修复 Windows 配置文件权限诊断误报,补充 Windows 提示与 WSL2 使用指引。
15
- - 三个仓库统一保持 shell 脚本使用 LF;源仓库 main 校验通过后,自动同步下游版本与
16
- 中英文 README 发布说明。
10
+ ## 0.6.12 更新
11
+
12
+ - 优化首次 AK/SK 配置:检查粘贴错误、隐藏双密钥、显示实际来源,保存后读回验证,
13
+ 明确区分区域元数据与凭据持久保存。
14
+ - 支持 Windows 凭据管理器,解释 macOS Keychain、Linux 密钥环依赖、PowerShell 会话
15
+ 作用域与 Windows/WSL2 环境隔离。
16
+ - 提前展示 SSH 私钥安装计划与实际配置,补充集群用户名、密钥有效期和 Windows OpenSSH
17
+ 指引,并增加 Linux、macOS、Windows 三平台配置回归测试。
17
18
  <!-- scnet-release:end -->
18
19
 
19
20
  > 本项目是独立维护的社区项目,与 DeepSeek Harness 兼容,但不是 DeepSeek 官方产品,也不表示 DeepSeek 对本项目提供背书、合作或授权。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-scnet",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
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",
@@ -11,6 +11,13 @@ available through the API. Never silently switch backends.
11
11
 
12
12
  ## First use and selection
13
13
 
14
+ - On first use or missing credentials, read `references/first-use.md` and explain in the user's
15
+ language what is needed, where to obtain it, and the next local command before starting setup.
16
+ Prefer OpenAPI (platform username + AK/SK); request SSH credentials only for SSH operations.
17
+ Explain-only/offline work needs no credentials. Reuse working configuration without re-entry.
18
+ - Never request AK/SK or private-key contents in chat. Give a command using the resolved absolute
19
+ Skill path so users can enter secrets in their own terminal; do not launch hidden prompts in
20
+ an agent tool session. Read `references/openapi.md` or `references/setup.md` for backend details.
14
21
  - Run `scripts/setup.sh new|modify|status|reset` for explicit configuration lifecycle.
15
22
  OpenAPI setup requires Python 3. With no saved preference, default to OpenAPI; credentials
16
23
  can come from environment variables or the supported OS credential store.
@@ -1 +1 @@
1
- 0.6.11
1
+ 0.6.12
@@ -0,0 +1,84 @@
1
+ # 首次使用:准备什么、从哪里获取
2
+
3
+ 首次介绍或缺少凭据时,先按用户当前任务说明下面的信息,再给本机配置命令。
4
+ 使用用户的语言;已经可用的配置直接复用,说明用法或离线准备无需先配置凭据。
5
+
6
+ ## 先选需要的连接方式
7
+
8
+ | 现在要做什么 | 推荐方式 | 用户需要准备 |
9
+ | --- | --- | --- |
10
+ | 查询队列、文件或管理标准 HPC 作业 | OpenAPI(默认) | SCNet 平台用户名、AK、SK |
11
+ | 安装环境、编译、执行远程 shell、API 未覆盖的操作 | SSH | 目标集群、集群/Linux 用户名、下载的 SSH 私钥文件路径 |
12
+ | 阅读说明、讨论参数、离线准备作业内容 | 暂不连接 | 无需凭据 |
13
+
14
+ 不要求用户同时准备两套凭据。平台账号需要已开通目标区域资源;只有 AK/SK 并不会开通资源。
15
+ 若还没有账号或资源,参考[SCNet 新手指南](https://www.scnet.cn/help/docs/mainsite/hpc/beginner-guide/)。
16
+
17
+ ## OpenAPI:三个输入从哪里来
18
+
19
+ 登录 SCNet,进入 **个人中心 → 访问控制**,生成并下载授权码文件。
20
+ 按[官方 AK/SK 说明](https://www.scnet.cn/ac/openapi/doc/2.0/api/safecertification/get-user-tokens-aksk.html)对应填写:
21
+
22
+ | 向导里的字段 | 填什么 |
23
+ | --- | --- |
24
+ | 平台用户名 | SCNet 平台用户名,不是区域内的 Linux 用户名或账号密码 |
25
+ | AccessKey / AK | 下载文件中 `Accesskey` 列的值 |
26
+ | SecretKey / SK | 同一份文件中 `Secretkey` 列的值 |
27
+
28
+ 每次仅复制一个值,不复制列名、整行表格或 `export` / `$env:` 赋值命令。
29
+ 这里使用 HPC OpenAPI 授权码,不使用模型服务的 API Key。用户无需手填区域 ID、调度器 ID
30
+ 或接口地址;认证后向导会发现已授权区域,再让用户选择。
31
+
32
+ AK/SK 只在用户自己的终端输入,两个字段都隐藏,输入时不显示星号是正常现象。
33
+ 不要求用户把授权码文件、密钥或带密钥的截图发到聊天。
34
+
35
+ ## SSH:下载什么、核对什么
36
+
37
+ 在 SCNet 控制台选择目标区域,进入 **命令行 E-Shell → 左上角 SSH 连接**,下载私钥,
38
+ 同时记录连接域名和端口。下载文件可能以 `_rsa.txt` 结尾,`.txt` 本身不代表文件选错。
39
+ 参见[官方 SSH 下载步骤](https://www2.scnet.cn/help/docs/mainsite/hpc/advanced-guide/advanced-efile/index.html)。
40
+
41
+ - 集群用户名取该区域实际 Linux 用户名;可在网页 E-Shell 中运行 `whoami` 核对。
42
+ - 私钥字段填本机下载文件的路径,保留原文件,不填私钥正文,也不选 `.pub` 公钥。
43
+ - 主机和端口默认来自所选 `clusters/<集群>.conf`;与控制台不一致时先处理 profile,避免连错目标。
44
+ - SSH 私钥和 AK/SK 不互通。配置、保存位置、有效期及 Windows OpenSSH 步骤见
45
+ [连接配置详解](setup.md)。
46
+
47
+ ## 给用户能直接运行的本机命令
48
+
49
+ Agent 应将下面的 `<Skill目录>` 替换为实际安装目录的绝对路径,保留引号,按用户系统提供
50
+ 一组命令。交互向导必须在用户可见且可输入的本机终端运行,不能在 Agent 后台工具里等待秘密输入。
51
+
52
+ Windows PowerShell(OpenAPI):
53
+
54
+ ```powershell
55
+ python "<Skill目录>/scripts/scnet.py" setup new --mode openapi
56
+ python "<Skill目录>/scripts/scnet.py" setup status
57
+ python "<Skill目录>/scripts/scnet.py" doctor
58
+ ```
59
+
60
+ macOS / Linux(OpenAPI):
61
+
62
+ ```bash
63
+ python3 "<Skill目录>/scripts/scnet.py" setup new --mode openapi
64
+ python3 "<Skill目录>/scripts/scnet.py" setup status
65
+ python3 "<Skill目录>/scripts/scnet.py" doctor
66
+ ```
67
+
68
+ 已有配置需更换凭据时,用 `setup modify --mode openapi`。SSH 自动安装在 macOS/Linux/WSL2
69
+ 使用 `python3 "<Skill目录>/scripts/scnet.py" setup new --mode ssh`,然后选择目标集群。
70
+ Windows 原生使用 OpenAPI 无需 WSL2;SSH 自动安装需要 WSL2 或按连接详解手工配置。
71
+
72
+ 保存提示要与本机相符:Windows 用系统凭据管理器,macOS 用系统 Keychain,Linux 用
73
+ `secret-tool`(Ubuntu/Debian 的 `libsecret-tools`)和已解锁的会话密钥环。
74
+ 无密钥环的服务器可用宿主秘密注入;完整示例与 PowerShell 会话范围见 [OpenAPI 说明](openapi.md)。
75
+
76
+ `setup status` 查看实际凭据来源及配置状态;`doctor` 只读检查认证和连接。
77
+ 区域配置完成不等于凭据已保存;不能仅凭 setup 成功就宣称以后不需要提供凭据。
78
+
79
+ ## 首次介绍示例
80
+
81
+ > 建议先用 OpenAPI 配置查询和作业管理。你需要 SCNet 平台用户名,以及“个人中心 →
82
+ > 访问控制”下载文件中的 Accesskey、Secretkey 两项。请只在本机终端向导里填写,
83
+ > 不要发到聊天。我会给你当前安装目录对应的命令,完成后用 status 和 doctor 检查。
84
+ > 如需远程编译或装环境,再到目标区域的“命令行 E-Shell → SSH 连接”下载 SSH 私钥。
@@ -26,10 +26,48 @@ Obtain AK/SK from the SCNet website:
26
26
  3. Open **Access Control / 访问控制**.
27
27
  4. Generate and download the authorization file containing AccessKey and SecretKey.
28
28
 
29
+ Use the platform username for `user`, the downloaded file's `Accesskey` column for AK,
30
+ and its `Secretkey` column for SK. Copy each value separately, without column labels or shell
31
+ assignments. These credentials differ from both cluster/Linux usernames and model-service API keys.
32
+ For the initial user briefing and commands that work outside the Skill directory, read
33
+ [first-use.md](first-use.md).
34
+
29
35
  Official instructions:
30
36
  <https://www.scnet.cn/ac/openapi/doc/2.0/api/safecertification/get-user-tokens-aksk.html>
31
37
 
32
- Preferred environment variables:
38
+ ## Guided first-time setup
39
+
40
+ Run `python3 scripts/scnet.py setup new --mode openapi` on macOS/Linux, or
41
+ `python scripts/scnet.py setup new --mode openapi` in Windows PowerShell.
42
+ Use `setup modify --mode openapi` to replace credentials for an existing setup.
43
+
44
+ Copy only each field's value, one at a time: platform login user, AccessKey, then SecretKey.
45
+ The cluster/Linux username is discovered later and may differ. Both key inputs are invisible;
46
+ surrounding quotes are removed, while pasted shell assignments, multiline values, and invisible
47
+ characters are rejected with a retry. The wizard does not display either key.
48
+
49
+ | Environment | Where persistent AK/SK are stored | Dependency and recovery |
50
+ |---|---|---|
51
+ | macOS | Login user's Keychain, service `scnet-hpc-openapi` | Built-in `security`; allow access/unlock when prompted; find the item in Keychain Access |
52
+ | Linux desktop | Secret Service, service `scnet-hpc-openapi` | Ubuntu/Debian: `sudo apt install libsecret-tools`; a running, unlocked keyring and session D-Bus are also needed |
53
+ | Linux headless / CI | Host-injected environment | No `libsecret-tools` required for API calls; installing the package alone does not create a working keyring |
54
+ | Native Windows | Current user's Windows Credential Manager, Generic Credential `scnet-hpc-openapi` | Built in; manage through Control Panel > Credential Manager > Windows Credentials |
55
+ | WSL2 | Linux credentials inside WSL2 | Windows Credential Manager and Windows environment changes are not automatically shared |
56
+
57
+ Saving is optional and happens after authentication. A saved item is read back before persistence is
58
+ reported as verified. If saving fails or is declined, **region setup is not credential persistence**:
59
+ the panel records `not_saved` and explains how to provide credentials for subsequent commands.
60
+ Use `setup status` for metadata and the actual credential source visible to the current process;
61
+ use `doctor --no-network` for local checks or `doctor` for a read-only connection check.
62
+ `setup reset` can delete the credential-store item separately from region metadata.
63
+
64
+ ### Environment injection
65
+
66
+ Set all three variables together. A partial set is rejected instead of mixing one account's
67
+ username/key with another account's saved credential set. Environment values take precedence over
68
+ the system store; clear stale overrides after saving replacement credentials.
69
+
70
+ Bash/Zsh, in the same terminal that will launch the client:
33
71
 
34
72
  ```bash
35
73
  export SCNET_OPENAPI_USER="<platform-user>"
@@ -37,10 +75,31 @@ export SCNET_OPENAPI_ACCESS_KEY="<access-key>"
37
75
  export SCNET_OPENAPI_SECRET_KEY="<secret-key>"
38
76
  ```
39
77
 
78
+ PowerShell, in the same window that will launch the client:
79
+
80
+ ```powershell
81
+ $env:SCNET_OPENAPI_USER = "<platform-user>"
82
+ $env:SCNET_OPENAPI_ACCESS_KEY = "<access-key>"
83
+ $env:SCNET_OPENAPI_SECRET_KEY = "<secret-key>"
84
+ python scripts/scnet.py --backend openapi doctor
85
+ ```
86
+
87
+ These `$env:` assignments last only for the current PowerShell session and its subsequently launched
88
+ children. Already-running desktop applications do not receive them. Prefer the interactive wizard and
89
+ Windows Credential Manager for daily desktop use. Do not use plaintext shell profiles, `setx`, or
90
+ checked-in environment files as the default credential store. Commands containing real secrets may
91
+ be recorded in shell history; host-managed secret injection avoids manual command entry.
92
+
93
+ To stop overriding the store in PowerShell:
94
+
95
+ ```powershell
96
+ Remove-Item Env:SCNET_OPENAPI_USER,Env:SCNET_OPENAPI_ACCESS_KEY,Env:SCNET_OPENAPI_SECRET_KEY -ErrorAction SilentlyContinue
97
+ ```
98
+
40
99
  The client signs the canonical AK/timestamp/user JSON with HMAC-SHA256 when requesting
41
100
  region tokens. Tokens are reused from a permission-restricted disk cache for up to four hours
42
101
  when available; an expired or unavailable cache triggers authentication. The setup panel can store the
43
- platform credential set in macOS Keychain or Linux Secret Service; otherwise use environment
102
+ platform credential set in macOS Keychain, Linux Secret Service, or Windows Credential Manager; otherwise use environment
44
103
  variables. Region IDs, region users, schedulers, and home paths are discovered and cached as
45
104
  non-secret metadata.
46
105
 
@@ -3,6 +3,64 @@
3
3
  集群的主机名、端口等参数来自 `clusters/<集群短名>.conf`。
4
4
  本文用 `<集群>`、`<主机名>`、`<端口>` 表示从 profile 读出的值。
5
5
 
6
+ ## 首次配置前先分清三件事
7
+
8
+ 1. **凭据**:在控制台选择目标区域,进入“命令行 E-Shell → 左上角 SSH 连接”,下载私钥并
9
+ 记录域名和端口([官方步骤](https://www2.scnet.cn/help/docs/mainsite/hpc/advanced-guide/advanced-efile/index.html))。
10
+ 它与 OpenAPI 的 AK/SK 不同;保留原始文件,可能以 `_rsa.txt` 结尾,不要选择 `.pub` 公钥。
11
+ 2. **用户名**:填写区域内的集群/Linux 用户名,不一定等于 SCNet 平台登录名;文件名只作为
12
+ 自动推断线索,不确定时在该区域网页 E-Shell 中执行 `whoami` 核对。
13
+ 3. **运行环境**:macOS/Linux/WSL2 可运行自动安装脚本。Windows 原生 OpenSSH 的配置和
14
+ WSL2 里的 `~/.ssh` 不共享,在哪个环境运行作业工具,就在该环境配置 SSH。
15
+
16
+ 在 macOS/Linux/WSL2 运行 `./scripts/setup.sh new --backend ssh`,或
17
+ `python3 scripts/scnet.py setup new --mode ssh`。路径带空格时可使用外层引号;向导只需要路径,
18
+ 不要粘贴私钥正文。安装前会显示主机、端口、目标位置,安装后显示实际生效的 `ssh -G` 参数。
19
+
20
+ 安装位置为 `~/.ssh/id_rsa_<集群>`,配置为 `~/.ssh/config`。不同的旧私钥先备份;只更新
21
+ 本工具管理的 Host 块,其他已有 Host 保留。保留旧 Host 时,务必核对实际 IdentityFile
22
+ 是否指向新密钥。`--skip-connect` 只保存元数据,不代表私钥已安装或连接已验证。
23
+
24
+ 私钥有口令时先执行 `ssh-add <私钥文件路径>`;如果无法解析但没有设置口令,重新下载原始
25
+ 文件,不要把损坏的格式误认为“有口令”。`Permission denied` 还可能表示私钥过期、用户名
26
+ 不匹配或 IP 白名单限制,不只是输入错误。
27
+
28
+ ## Windows 原生 OpenSSH(手工配置)
29
+
30
+ 若使用完整 Bash 工作流,优先在 WSL2 运行上方安装向导。若只使用 Python SSH backend,
31
+ 可手工配置 Windows OpenSSH,然后运行 `python scripts/scnet.py setup modify --mode ssh`
32
+ 接管已有别名;这不会让 Bash 安装脚本变成 Windows 原生脚本。
33
+
34
+ 1. 在 PowerShell 执行 `Get-Command ssh,scp,ssh-keygen`,确认 Windows OpenSSH 客户端已安装。
35
+ 2. 把下载的私钥复制到 `$env:USERPROFILE\.ssh\id_rsa_<集群>`;路径可自选,但需与配置一致。
36
+ 3. 对复制的目标私钥限制访问权限(下方路径占位符需替换):
37
+
38
+ ```powershell
39
+ $sshKeyPath = "$env:USERPROFILE\.ssh\id_rsa_<集群>"
40
+ $sshIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
41
+ icacls $sshKeyPath /inheritance:r
42
+ icacls $sshKeyPath /grant:r "${sshIdentity}:(R)"
43
+ notepad "$env:USERPROFILE\.ssh\config"
44
+ ```
45
+
46
+ 在编辑器里追加以下块,替换为 profile 和账号中的实际值,保存为无 BOM 的 UTF-8 文本;
47
+ 保留已有无关的 Host 配置。Windows 不添加 `UseKeychain` 和 Unix socket 连接复用选项:
48
+
49
+ ```text
50
+ Host <集群>
51
+ HostName <主机名>
52
+ Port <端口>
53
+ User <集群用户名>
54
+ IdentityFile ~/.ssh/id_rsa_<集群>
55
+ IdentitiesOnly yes
56
+ ServerAliveInterval 60
57
+ ServerAliveCountMax 3
58
+ ```
59
+
60
+ 先执行 `ssh -G <集群>` 核对 `hostname/user/port/identityfile`,再执行 `ssh <集群>`。
61
+ 首次主机指纹请与平台提供的指纹核对。口令私钥需要当前环境的 ssh-agent,Windows 中的
62
+ `ssh-add` 与 WSL2 中的 ssh-agent 也互相独立。
63
+
6
64
  ## 手工配置
7
65
 
8
66
  ```bash
@@ -34,6 +34,8 @@ from scnet_credentials import (
34
34
  CredentialError,
35
35
  delete_openapi_credentials,
36
36
  load_openapi_credentials,
37
+ load_secure_credentials,
38
+ normalize_credential,
37
39
  secure_store_name,
38
40
  store_openapi_credentials,
39
41
  )
@@ -414,6 +416,78 @@ def _ask_secret(prompt: str) -> str:
414
416
  return getpass.getpass(f"{prompt}: ").strip()
415
417
 
416
418
 
419
+ def _ask_credential(label: str, *, secret: bool = False, default: str = "") -> str:
420
+ while True:
421
+ value = _ask_secret(label) if secret else _ask(label, default)
422
+ try:
423
+ return normalize_credential(value, label)
424
+ except CredentialError as exc:
425
+ print(f"输入未通过检查:{exc}")
426
+
427
+
428
+ def _credential_help() -> None:
429
+ provider = secure_store_name()
430
+ print("\n凭据保存方式(AK/SK 与 SSH 私钥是两套独立凭据):")
431
+ if provider == "Windows Credential Manager":
432
+ print("Windows:可保存到当前用户的 Windows 凭据管理器,无需安装额外工具。")
433
+ print("查看/删除:控制面板 → 凭据管理器 → Windows 凭据 → 普通凭据 → scnet-hpc-openapi。")
434
+ elif provider == "macOS Keychain":
435
+ print("macOS:使用系统自带 Keychain,无需安装依赖;系统可能提示解锁或授权访问。")
436
+ print("可在“钥匙串访问”搜索 scnet-hpc-openapi 查看或删除。")
437
+ elif provider == "Secret Service":
438
+ print("Linux:已找到 secret-tool;保存时还需要运行且已解锁的 Secret Service 密钥环。")
439
+ print("仅安装 libsecret-tools 不保证密钥环可用,无桌面/无 D-Bus 的服务器可使用环境变量。")
440
+ else:
441
+ print("当前没有可用的系统凭据工具;仍可校验 AK/SK,但不会自动持久保存。")
442
+ print("Ubuntu/Debian:sudo apt install libsecret-tools;还需桌面密钥环服务运行并解锁。")
443
+ print("环境变量优先于系统凭据库;config.json/openapi.json 只保存账号与区域等非敏感信息。")
444
+ print("PowerShell 的 $env: 设置仅对当前会话及其子进程生效,已启动的桌面应用不会自动获得。")
445
+
446
+
447
+ def _print_environment_help() -> None:
448
+ if platform.system() == "Windows":
449
+ print("在运行本程序的同一个 PowerShell 中注入;以下占位符需替换为真实值:")
450
+ print('$env:SCNET_OPENAPI_USER = "<平台用户名>"')
451
+ print('$env:SCNET_OPENAPI_ACCESS_KEY = "<AccessKey>"')
452
+ print('$env:SCNET_OPENAPI_SECRET_KEY = "<SecretKey>"')
453
+ print("关闭窗口后失效;日常桌面使用建议重新配置并选择系统凭据库。")
454
+ else:
455
+ print("在运行本程序的同一个 Bash/Zsh 中注入,或使用宿主的秘密注入功能:")
456
+ print('export SCNET_OPENAPI_USER="<平台用户名>"')
457
+ print('export SCNET_OPENAPI_ACCESS_KEY="<AccessKey>"')
458
+ print('export SCNET_OPENAPI_SECRET_KEY="<SecretKey>"')
459
+ print("命令中的秘密可能进入 shell 历史;不要把 AK/SK 写入仓库、普通配置或聊天。")
460
+
461
+
462
+ def _private_key_path(value: str) -> Path:
463
+ value = value.strip()
464
+ if "PRIVATE KEY" in value or "\n" in value or "\r" in value:
465
+ raise BackendError("这里只接受私钥文件路径,不接受私钥正文。")
466
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
467
+ value = value[1:-1]
468
+ path = Path(value).expanduser()
469
+ if not value or path.suffix == ".pub":
470
+ raise BackendError("请选择私钥文件路径,不要选择 .pub 公钥。")
471
+ return path
472
+
473
+
474
+ def _ssh_setup_help(cluster: str, profile: Mapping[str, str]) -> None:
475
+ print("\nSSH 私钥获取:SCNet 控制台选择目标区域 → 命令行 E-Shell → 左上角 SSH 连接。")
476
+ print("下载原始私钥文件(可能以 _rsa.txt 结尾),记录页面上的域名和端口,与下方目标核对。")
477
+ print("官方步骤:https://www2.scnet.cn/help/docs/mainsite/hpc/advanced-guide/advanced-efile/index.html")
478
+ print("AK/SK 授权码不能用于 SSH。集群用户名可在该区域网页 E-Shell 运行 whoami 核对。")
479
+ print("输入文件路径即可(支持空格和外层引号),不要粘贴私钥正文,也不要选择 .pub 公钥。")
480
+ print(f"连接目标:{profile.get('SSH_HOST')}:{profile.get('SSH_PORT', '22')},别名 {cluster}。")
481
+ print("用户名是该区域的集群/Linux 用户名,可能与 OpenAPI 平台账号不同。")
482
+ print(f"自动安装位置:{Path.home() / '.ssh' / ('id_rsa_' + cluster)}")
483
+ print(f"SSH 配置文件:{Path.home() / '.ssh/config'}")
484
+ print("不同的旧私钥会备份;已有非本工具管理的 Host 块会保留,不会自动改写。")
485
+ if platform.system() == "Windows":
486
+ print("Windows 原生 OpenSSH 与 WSL2 的 ~/.ssh 互相独立;自动安装脚本需在 WSL2 内执行。")
487
+ print("已有 Windows OpenSSH 配置可用 setup modify 接管;手工安装步骤见 references/setup.md。")
488
+ print("若私钥设有口令,先用 ssh-add 加载;连接失败还需检查有效期、端口及平台 IP 白名单。")
489
+
490
+
417
491
  def _ask_yes_no(prompt: str, default: bool = True) -> bool:
418
492
  hint = "Y/n" if default else "y/N"
419
493
  answer = input(f"{prompt} [{hint}]: ").strip().lower()
@@ -708,15 +782,14 @@ def _print_terminal_help() -> None:
708
782
  )
709
783
  elif system == "Windows":
710
784
  print("Windows:输入编号并按 Enter 选择;Ctrl+C 可取消。敏感输入不会回显。")
711
- print("完整脚本环境建议使用 WSL2;Git Bash 尚未作为完整环境验证。")
712
- print(
713
- "当前实现未接入 Windows 凭据管理器;AK/SK 请通过环境变量注入:"
714
- "SCNET_OPENAPI_USER / SCNET_OPENAPI_ACCESS_KEY / SCNET_OPENAPI_SECRET_KEY。"
715
- )
785
+ print("OpenAPI 配置可直接运行 Python,并保存到 Windows 凭据管理器;无需 Bash。")
786
+ print("SSH 自动安装及完整 shell 脚本环境请使用 WSL2。")
716
787
  print()
717
788
 
718
789
 
719
790
  def _run_setup_ssh(cluster: str, key_path: str, username: str) -> None:
791
+ if platform.system() == "Windows":
792
+ raise BackendError("Windows 原生环境无法执行 SSH 自动安装脚本;请在 WSL2 中运行 setup,或按 references/setup.md 配置 Windows OpenSSH。")
720
793
  script = REPO_ROOT / "scripts" / "setup-ssh.sh"
721
794
  command = [str(script), "--cluster", cluster, key_path, username]
722
795
  try:
@@ -880,8 +953,10 @@ def _resolved_ssh_connection(
880
953
  }
881
954
 
882
955
 
883
- def _ssh_key_metadata(cluster: str, source: str, username: str) -> dict[str, Any]:
884
- installed = Path.home() / ".ssh" / f"id_rsa_{cluster}"
956
+ def _ssh_key_metadata(
957
+ cluster: str, source: str, username: str, *, installed_path: str | None = None
958
+ ) -> dict[str, Any]:
959
+ installed = Path(installed_path).expanduser() if installed_path else Path.home() / ".ssh" / f"id_rsa_{cluster}"
885
960
  metadata: dict[str, Any] = {
886
961
  "username": username,
887
962
  "key_path": str(installed),
@@ -932,7 +1007,7 @@ def _print_setup_status(config: Mapping[str, Any]) -> None:
932
1007
  expiry = profile.get("key_expires_at") or "未知"
933
1008
  print(
934
1009
  f" - {name}: user={profile.get('username') or '未知'}, "
935
- f"key_expires={expiry}"
1010
+ f"key={profile.get('key_path') or '未安装/未记录'}, key_expires={expiry}"
936
1011
  )
937
1012
  configured_names = set(clusters) if isinstance(clusters, dict) else set()
938
1013
  for item in list_profiles(REPO_ROOT):
@@ -963,6 +1038,11 @@ def _print_setup_status(config: Mapping[str, Any]) -> None:
963
1038
  f"OpenAPI:user={openapi.get('platform_user') or '未知'}, "
964
1039
  f"provider={openapi.get('credential_provider') or '环境变量'}"
965
1040
  )
1041
+ try:
1042
+ active_credentials, active_provider = load_openapi_credentials()
1043
+ print(f" 当前进程实际凭据来源:{active_provider if active_credentials else '未检测到凭据(区域缓存不等于已认证)'}")
1044
+ except CredentialError as exc:
1045
+ print(f" 当前凭据检查失败:{exc}")
966
1046
  print(f" 已发现区域:{len(regions)},已启用区域:{len(enabled)}")
967
1047
  for region_id in enabled:
968
1048
  region = regions.get(str(region_id), {})
@@ -1124,6 +1204,7 @@ def setup_panel(
1124
1204
  selected_cluster = cluster
1125
1205
  result["cluster"] = cluster
1126
1206
  _, profile = load_profile(REPO_ROOT, cluster)
1207
+ _ssh_setup_help(cluster, profile)
1127
1208
  existing_profile = existing_clusters.get(cluster, {})
1128
1209
  if not isinstance(existing_profile, dict):
1129
1210
  existing_profile = {}
@@ -1140,17 +1221,19 @@ def setup_panel(
1140
1221
  key_path = ""
1141
1222
  if not skip_connect:
1142
1223
  detected_key = str(existing_profile.get("key_path") or "")
1224
+ if platform.system() == "Windows" and not detected_connection:
1225
+ raise BackendError("尚未检测到 Windows OpenSSH 连接。请先按 references/setup.md 手工配置,或在 WSL2 运行 SSH 安装向导。")
1143
1226
  if action == "modify" and detected_key:
1144
1227
  rotate = _ask_yes_no(
1145
1228
  "是否轮换为新的 SSH 私钥?", default=False
1146
1229
  )
1147
1230
  key_path = (
1148
- str(Path(_ask("新私钥文件路径")).expanduser())
1231
+ str(_private_key_path(_ask("新私钥文件路径")))
1149
1232
  if rotate
1150
1233
  else detected_key
1151
1234
  )
1152
1235
  else:
1153
- key_path = str(Path(_ask("私钥文件路径")).expanduser())
1236
+ key_path = str(_private_key_path(_ask("私钥文件路径")))
1154
1237
  if not key_path or not Path(key_path).is_file():
1155
1238
  raise BackendError(f"找不到私钥文件: {key_path}")
1156
1239
  detected_user = _detect_ssh_username(
@@ -1162,6 +1245,9 @@ def setup_panel(
1162
1245
  )
1163
1246
  if not username:
1164
1247
  raise BackendError("无法自动探测 SSH 用户名,请手工输入")
1248
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", username):
1249
+ raise BackendError("集群用户名包含空白或不支持的字符,请只填写用户名。")
1250
+ print(f"配置摘要:集群={cluster},用户={username},私钥={key_path or '未安装(skip-connect)'}")
1165
1251
  clusters = dict(existing_clusters)
1166
1252
  clusters[cluster] = {**existing_profile, "username": username}
1167
1253
  result["ssh"] = {"clusters": clusters}
@@ -1181,6 +1267,8 @@ def setup_panel(
1181
1267
  if not _ask_yes_no(prompt, default=True):
1182
1268
  print("已跳过 SSH 写入;可稍后单独运行 setup-ssh.sh。")
1183
1269
  elif adopted:
1270
+ if username != detected_connection.get("username"):
1271
+ raise BackendError("接管时输入的用户名与现有 SSH 别名不同;请先修改 ~/.ssh/config,再重新运行 setup。")
1184
1272
  completed = subprocess.run(
1185
1273
  [
1186
1274
  "ssh",
@@ -1202,7 +1290,7 @@ def setup_panel(
1202
1290
  )
1203
1291
  clusters[cluster] = {
1204
1292
  **existing_profile,
1205
- **_ssh_key_metadata(cluster, key_path, username),
1293
+ **_ssh_key_metadata(cluster, key_path, username, installed_path=key_path),
1206
1294
  "adopted": True,
1207
1295
  }
1208
1296
  else:
@@ -1238,8 +1326,16 @@ def setup_panel(
1238
1326
  "官方说明:https://www.scnet.cn/ac/openapi/doc/2.0/"
1239
1327
  "api/safecertification/get-user-tokens-aksk.html"
1240
1328
  )
1241
- print("SecretKey 输入时不会显示字符或星号,这是正常现象。\n")
1242
- saved_credentials, saved_provider = load_openapi_credentials()
1329
+ _credential_help()
1330
+ print("字段对应:平台用户名 = SCNet 平台用户名;AK = 下载文件的 Accesskey 列;SK = Secretkey 列。")
1331
+ print("无需填写区域 ID 或接口地址,认证后自动发现已授权区域;账号需已开通区域资源。")
1332
+ print("\n请依次复制平台用户名、AccessKey、SecretKey 的值;不需要输入 export 或 $env: 命令。")
1333
+ print("AccessKey 和 SecretKey 都不会显示字符或星号;外层引号会自动去除。\n")
1334
+ try:
1335
+ saved_credentials, saved_provider = load_openapi_credentials()
1336
+ except CredentialError as exc:
1337
+ print(f"现有凭据无法使用:{exc}")
1338
+ saved_credentials, saved_provider = None, None
1243
1339
  use_saved = bool(saved_credentials) and _ask_yes_no(
1244
1340
  f"检测到 {saved_provider} 中的 OpenAPI 凭据,是否使用?",
1245
1341
  default=True,
@@ -1250,14 +1346,17 @@ def setup_panel(
1250
1346
  access_key = saved_credentials["access_key"]
1251
1347
  secret_key = saved_credentials["secret_key"]
1252
1348
  else:
1253
- api_user = _ask(
1349
+ api_user = _ask_credential(
1254
1350
  "平台用户名",
1255
- str(existing_openapi.get("platform_user") or ""),
1351
+ default=str(existing_openapi.get("platform_user") or ""),
1256
1352
  )
1257
- access_key = _ask_secret("AccessKey")
1258
- secret_key = _ask_secret("SecretKey")
1259
- if not api_user or not access_key or not secret_key:
1260
- raise BackendError("AK/SK 配置不完整")
1353
+ while True:
1354
+ access_key = _ask_credential("AccessKey", secret=True)
1355
+ secret_key = _ask_credential("SecretKey", secret=True)
1356
+ if access_key != secret_key:
1357
+ break
1358
+ print("AccessKey 与 SecretKey 完全相同,可能重复粘贴了同一个字段,请重新输入。")
1359
+ print(f"账号:{api_user};AK/SK 已读取且不回显。下一步只读校验认证并发现授权区域。")
1261
1360
  backend_instance, previous = _with_openapi_credentials(
1262
1361
  {},
1263
1362
  user=api_user,
@@ -1335,8 +1434,8 @@ def setup_panel(
1335
1434
  selected = enabled_contexts[selected_index]
1336
1435
  finally:
1337
1436
  _restore_environment(previous)
1338
- stored_provider = saved_provider
1339
- if entered_credentials and secure_store_name():
1437
+ stored_provider = saved_provider if use_saved else None
1438
+ if (entered_credentials or saved_provider == "environment") and secure_store_name():
1340
1439
  if _ask_yes_no(
1341
1440
  f"是否将 AK/SK 保存到 {secure_store_name()}?",
1342
1441
  default=True,
@@ -1345,10 +1444,22 @@ def setup_panel(
1345
1444
  provider = store_openapi_credentials(
1346
1445
  api_user, access_key, secret_key
1347
1446
  )
1447
+ expected = {"user": api_user, "access_key": access_key, "secret_key": secret_key}
1448
+ if load_secure_credentials() != expected:
1449
+ raise CredentialError("保存后读回校验失败;尚不能确认凭据已持久保存。")
1348
1450
  stored_provider = provider
1349
1451
  print(f"OpenAPI 凭据已保存到 {provider}。")
1350
1452
  except CredentialError as exc:
1351
1453
  print(f"无法保存到安全凭据库:{exc}")
1454
+ if stored_provider is None:
1455
+ print("凭据未保存:本次认证已完成,但后续命令仍需注入环境变量或重新保存 AK/SK。")
1456
+ _print_environment_help()
1457
+ elif stored_provider == "environment":
1458
+ print("凭据来源:当前进程环境变量;未另存到系统凭据库,重启后需由宿主重新注入。")
1459
+ else:
1460
+ print(f"持久保存已验证:{stored_provider};条目名 scnet-hpc-openapi。")
1461
+ if any(os.environ.get(key) for key in ("SCNET_OPENAPI_USER", "SCNET_OPENAPI_ACCESS_KEY", "SCNET_OPENAPI_SECRET_KEY")):
1462
+ print("注意:当前 shell 仍有环境变量,它们优先于刚保存的凭据;请清除旧变量后运行 doctor。")
1352
1463
  selected_schedulers = selected.get("schedulers") or []
1353
1464
  default_scheduler = (
1354
1465
  selected_schedulers[0].get("id")
@@ -1357,7 +1468,7 @@ def setup_panel(
1357
1468
  )
1358
1469
  result["openapi"] = {
1359
1470
  "platform_user": api_user,
1360
- "credential_provider": stored_provider,
1471
+ "credential_provider": stored_provider or "not_saved",
1361
1472
  "default_region_id": selected["region_id"],
1362
1473
  "enabled_region_ids": [
1363
1474
  item["region_id"] for item in enabled_contexts
@@ -1378,7 +1489,7 @@ def setup_panel(
1378
1489
  },
1379
1490
  }
1380
1491
  print(
1381
- "\nHPC OpenAPI 配置完成:\n"
1492
+ "\nHPC OpenAPI 区域选择结果(凭据状态见上方):\n"
1382
1493
  f" 默认区域:{selected.get('name') or selected['region_id']}"
1383
1494
  f" ({selected['region_id']})\n"
1384
1495
  f" 区域用户:{selected.get('username')}\n"
@@ -1393,11 +1504,10 @@ def setup_panel(
1393
1504
  path = save_user_config(result)
1394
1505
  print(f"\n已保存非敏感配置:{path}")
1395
1506
  print("后续可用 `python3 scripts/scnet.py config` 查看。")
1396
- if configure_openapi and not secure_store_name():
1397
- print("系统没有可用安全凭据库;请通过环境变量注入 AK/SK。")
1398
1507
  doctor_cluster = selected_cluster or str(current.get("cluster") or "")
1399
1508
  suffix = f" --cluster {shlex.quote(doctor_cluster)}" if doctor_cluster else ""
1400
- print(f"下一步:python3 scripts/scnet.py{suffix} doctor")
1509
+ interpreter = "python" if platform.system() == "Windows" else "python3"
1510
+ print(f"下一步:{interpreter} scripts/scnet.py --backend {default_backend}{suffix} doctor")
1401
1511
  return result
1402
1512
 
1403
1513
 
@@ -1460,18 +1570,29 @@ def doctor_report(
1460
1570
  if alias and shutil.which("ssh"):
1461
1571
  completed = subprocess.run(
1462
1572
  ["ssh", "-G", alias],
1463
- stdout=subprocess.DEVNULL,
1573
+ stdout=subprocess.PIPE,
1464
1574
  stderr=subprocess.PIPE,
1465
1575
  text=True,
1466
1576
  check=False,
1467
1577
  )
1468
- add(
1469
- "ssh_config",
1470
- completed.returncode == 0,
1471
- f"alias={alias}"
1472
- if completed.returncode == 0
1473
- else completed.stderr.strip(),
1578
+ resolved = {}
1579
+ for line in completed.stdout.splitlines():
1580
+ field, _, value = line.partition(" ")
1581
+ if field in {"hostname", "port", "user", "identityfile"}:
1582
+ resolved.setdefault(field, value)
1583
+ config_ok = (
1584
+ completed.returncode == 0
1585
+ and resolved.get("hostname") == profile.get("SSH_HOST")
1586
+ and resolved.get("port") == str(profile.get("SSH_PORT") or "22")
1587
+ )
1588
+ detail = (
1589
+ f"alias={alias}, host={resolved.get('hostname', '未知')}, "
1590
+ f"port={resolved.get('port', '未知')}, user={resolved.get('user', '未知')}, "
1591
+ f"key={resolved.get('identityfile', '未配置')}"
1474
1592
  )
1593
+ if not config_ok:
1594
+ detail += "; 别名未解析到 profile 的主机/端口,请检查当前环境的 ~/.ssh/config"
1595
+ add("ssh_config", config_ok, detail)
1475
1596
  if not no_network:
1476
1597
  completed = subprocess.run(
1477
1598
  [
@@ -1537,7 +1658,11 @@ def doctor_report(
1537
1658
 
1538
1659
  elif backend_name == "openapi":
1539
1660
  has_token = bool(os.environ.get("SCNET_OPENAPI_TOKEN"))
1540
- credentials, credential_provider = load_openapi_credentials()
1661
+ try:
1662
+ credentials, credential_provider = load_openapi_credentials()
1663
+ except CredentialError as exc:
1664
+ credentials, credential_provider = None, None
1665
+ add("credential_store", False, str(exc))
1541
1666
  has_aksk = bool(credentials)
1542
1667
  add(
1543
1668
  "openapi_credentials",
@@ -1547,7 +1672,7 @@ def doctor_report(
1547
1672
  else (
1548
1673
  f"检测到 AK/SK({credential_provider})"
1549
1674
  if has_aksk
1550
- else "未检测到 OpenAPI 凭据"
1675
+ else "未检测到可用凭据;运行 setup new/modify --mode openapi,或同时注入三项环境变量"
1551
1676
  ),
1552
1677
  )
1553
1678
  openapi_config = user_config.get("openapi", {})
@@ -1757,7 +1882,10 @@ def main(argv: list[str] | None = None) -> int:
1757
1882
  if args.operation == "doctor" and not data.get("ok", False):
1758
1883
  return 1
1759
1884
  return 0
1760
- except BackendError as exc:
1885
+ except (KeyboardInterrupt, EOFError):
1886
+ print("\n已取消输入;未完成的配置请重新运行 setup。", file=sys.stderr)
1887
+ return 130
1888
+ except (BackendError, CredentialError) as exc:
1761
1889
  if args.json:
1762
1890
  print(
1763
1891
  json.dumps(
@@ -89,8 +89,9 @@ def save_user_config(data: dict[str, Any]) -> Path:
89
89
  prefix=".config.", suffix=".tmp", dir=str(path.parent)
90
90
  )
91
91
  try:
92
- os.fchmod(fd, 0o600)
93
92
  with os.fdopen(fd, "w", encoding="utf-8") as stream:
93
+ if os.name == "posix":
94
+ os.fchmod(stream.fileno(), 0o600)
94
95
  json.dump(clean, stream, ensure_ascii=False, indent=2, sort_keys=True)
95
96
  stream.write("\n")
96
97
  os.replace(temporary, path)
@@ -110,8 +111,9 @@ def _save_json_sidecar(path: Path, data: dict[str, Any]) -> Path:
110
111
  prefix=f".{path.stem}.", suffix=".tmp", dir=str(path.parent)
111
112
  )
112
113
  try:
113
- os.fchmod(fd, 0o600)
114
114
  with os.fdopen(fd, "w", encoding="utf-8") as stream:
115
+ if os.name == "posix":
116
+ os.fchmod(stream.fileno(), 0o600)
115
117
  json.dump(clean, stream, ensure_ascii=False, indent=2, sort_keys=True)
116
118
  stream.write("\n")
117
119
  os.replace(temporary, path)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import platform
8
+ import re
8
9
  import shutil
9
10
  import subprocess
10
11
  from typing import Any
@@ -17,6 +18,90 @@ class CredentialError(RuntimeError):
17
18
  """A user-facing credential-store failure."""
18
19
 
19
20
 
21
+ def _credential_command(command: list[str], **options: Any) -> subprocess.CompletedProcess:
22
+ try:
23
+ return subprocess.run(command, timeout=30, **options)
24
+ except (OSError, subprocess.TimeoutExpired):
25
+ # TimeoutExpired includes argv, which contains the Keychain payload.
26
+ raise CredentialError("系统凭据操作失败或超时;请检查密钥环服务及解锁状态后重试。") from None
27
+
28
+
29
+ def normalize_credential(value: str, label: str) -> str:
30
+ """Accept surrounding paste quotes, but never echo a credential in an error."""
31
+ value = value.strip().lstrip("\ufeff")
32
+ if len(value) >= 2 and (value[0], value[-1]) in {
33
+ ('"', '"'), ("'", "'"), ("“", "”"), ("‘", "’")
34
+ }:
35
+ value = value[1:-1].strip()
36
+ if not value:
37
+ raise CredentialError(f"{label} 不能为空,请只粘贴对应字段的值。")
38
+ if re.match(r"(?i)^(export\s|\$env:|SCNET_OPENAPI_|(?:AccessKey|SecretKey)\s*[:=])", value):
39
+ raise CredentialError(f"{label} 收到了变量赋值或字段名;请只粘贴值,不要粘贴整行命令。")
40
+ if any(character.isspace() or ord(character) < 32 or character in "\u200b\ufeff\u200e\u200f" for character in value):
41
+ raise CredentialError(f"{label} 含空白、换行或不可见字符,请重新复制单个字段。")
42
+ return value
43
+
44
+
45
+ def _windows_credential(action: str, payload: str = "", user: str = "") -> str | bool | None:
46
+ """Use the current Windows user's Generic Credential store, without extra packages."""
47
+ import ctypes
48
+ from ctypes import wintypes
49
+
50
+ class Credential(ctypes.Structure):
51
+ _fields_ = [
52
+ ("Flags", wintypes.DWORD), ("Type", wintypes.DWORD),
53
+ ("TargetName", wintypes.LPWSTR), ("Comment", wintypes.LPWSTR),
54
+ ("LastWritten", wintypes.FILETIME), ("CredentialBlobSize", wintypes.DWORD),
55
+ ("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)),
56
+ ("Persist", wintypes.DWORD), ("AttributeCount", wintypes.DWORD),
57
+ ("Attributes", ctypes.c_void_p), ("TargetAlias", wintypes.LPWSTR),
58
+ ("UserName", wintypes.LPWSTR),
59
+ ]
60
+
61
+ api = ctypes.WinDLL("Advapi32.dll", use_last_error=True)
62
+ pointer = ctypes.POINTER(Credential)
63
+ api.CredReadW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(pointer)]
64
+ api.CredReadW.restype = wintypes.BOOL
65
+ api.CredWriteW.argtypes = [pointer, wintypes.DWORD]
66
+ api.CredWriteW.restype = wintypes.BOOL
67
+ api.CredDeleteW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD]
68
+ api.CredDeleteW.restype = wintypes.BOOL
69
+ api.CredFree.argtypes = [ctypes.c_void_p]
70
+ api.CredFree.restype = None
71
+
72
+ if action == "read":
73
+ item = pointer()
74
+ success = api.CredReadW(SERVICE, 1, 0, ctypes.byref(item))
75
+ if success:
76
+ try:
77
+ return ctypes.string_at(item.contents.CredentialBlob, item.contents.CredentialBlobSize).decode("utf-8")
78
+ finally:
79
+ api.CredFree(item)
80
+ elif action == "write":
81
+ encoded = payload.encode("utf-8")
82
+ if len(encoded) > 2560:
83
+ raise CredentialError("凭据超出 Windows 凭据管理器容量,请检查是否粘贴了整个文件。")
84
+ buffer = (ctypes.c_ubyte * len(encoded)).from_buffer_copy(encoded)
85
+ item = Credential()
86
+ item.Type, item.TargetName, item.UserName = 1, SERVICE, user
87
+ item.CredentialBlobSize, item.CredentialBlob = len(encoded), buffer
88
+ item.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE: same user, across logon sessions.
89
+ success = api.CredWriteW(ctypes.byref(item), 0)
90
+ elif action == "delete":
91
+ success = api.CredDeleteW(SERVICE, 1, 0)
92
+ else:
93
+ raise ValueError("unknown credential operation")
94
+ if success:
95
+ return True
96
+ error = ctypes.get_last_error()
97
+ if error == 1168 and action in {"read", "delete"}:
98
+ return None
99
+ raise CredentialError(
100
+ f"Windows 凭据管理器{action}失败(错误码 {error});"
101
+ "请在已登录的桌面用户会话运行,或使用环境变量注入。"
102
+ )
103
+
104
+
20
105
  def _valid(data: Any) -> bool:
21
106
  return isinstance(data, dict) and all(
22
107
  isinstance(data.get(key), str) and data[key]
@@ -30,10 +115,17 @@ def environment_credentials() -> dict[str, str] | None:
30
115
  "access_key": os.environ.get("SCNET_OPENAPI_ACCESS_KEY", ""),
31
116
  "secret_key": os.environ.get("SCNET_OPENAPI_SECRET_KEY", ""),
32
117
  }
33
- return data if _valid(data) else None
118
+ if not any(data.values()):
119
+ return None
120
+ missing = [name for name, value in data.items() if not value]
121
+ if missing:
122
+ raise CredentialError("OpenAPI 环境变量不完整;请同时设置 USER、ACCESS_KEY、SECRET_KEY,或清除这组三个变量后使用系统凭据库。")
123
+ return {key: normalize_credential(value, key) for key, value in data.items()}
34
124
 
35
125
 
36
126
  def secure_store_name() -> str | None:
127
+ if platform.system() == "Windows":
128
+ return "Windows Credential Manager"
37
129
  if platform.system() == "Darwin" and shutil.which("security"):
38
130
  return "macOS Keychain"
39
131
  if shutil.which("secret-tool"):
@@ -43,19 +135,28 @@ def secure_store_name() -> str | None:
43
135
 
44
136
  def load_secure_credentials() -> dict[str, str] | None:
45
137
  provider = secure_store_name()
138
+ if provider == "Windows Credential Manager":
139
+ payload = _windows_credential("read")
140
+ if payload is None:
141
+ return None
142
+ try:
143
+ data = json.loads(payload)
144
+ except (ValueError, TypeError):
145
+ raise CredentialError("Windows 中已保存的 OpenAPI 凭据格式无效,请运行 setup modify --mode openapi 重新配置。") from None
146
+ return data if _valid(data) else None
46
147
  if provider == "macOS Keychain":
47
148
  command = ["security", "find-generic-password", "-s", SERVICE, "-w"]
48
149
  elif provider == "Secret Service":
49
150
  command = ["secret-tool", "lookup", "service", SERVICE]
50
151
  else:
51
152
  return None
52
- completed = subprocess.run(
53
- command,
54
- text=True,
55
- stdout=subprocess.PIPE,
56
- stderr=subprocess.DEVNULL,
57
- check=False,
58
- )
153
+ try:
154
+ completed = subprocess.run(
155
+ command, text=True, stdout=subprocess.PIPE,
156
+ stderr=subprocess.DEVNULL, check=False, timeout=30,
157
+ )
158
+ except (OSError, subprocess.TimeoutExpired):
159
+ raise CredentialError(f"无法读取 {provider};请检查密钥环服务是否运行并已解锁。") from None
59
160
  if completed.returncode != 0 or not completed.stdout.strip():
60
161
  return None
61
162
  try:
@@ -79,14 +180,17 @@ def store_openapi_credentials(
79
180
  user: str, access_key: str, secret_key: str
80
181
  ) -> str:
81
182
  data = {
82
- "user": user,
83
- "access_key": access_key,
84
- "secret_key": secret_key,
183
+ "user": normalize_credential(user, "平台用户名"),
184
+ "access_key": normalize_credential(access_key, "AccessKey"),
185
+ "secret_key": normalize_credential(secret_key, "SecretKey"),
85
186
  }
86
187
  if not _valid(data):
87
188
  raise CredentialError("OpenAPI credentials are incomplete")
88
189
  payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
89
190
  provider = secure_store_name()
191
+ if provider == "Windows Credential Manager":
192
+ _windows_credential("write", payload, data["user"])
193
+ return provider
90
194
  if provider == "macOS Keychain":
91
195
  command = [
92
196
  "security",
@@ -99,7 +203,7 @@ def store_openapi_credentials(
99
203
  "-w",
100
204
  payload,
101
205
  ]
102
- completed = subprocess.run(
206
+ completed = _credential_command(
103
207
  command,
104
208
  stdout=subprocess.DEVNULL,
105
209
  stderr=subprocess.PIPE,
@@ -116,7 +220,7 @@ def store_openapi_credentials(
116
220
  "user",
117
221
  user,
118
222
  ]
119
- completed = subprocess.run(
223
+ completed = _credential_command(
120
224
  command,
121
225
  input=payload,
122
226
  stdout=subprocess.DEVNULL,
@@ -131,20 +235,22 @@ def store_openapi_credentials(
131
235
  )
132
236
  if completed.returncode != 0:
133
237
  raise CredentialError(
134
- completed.stderr.strip() or f"failed to write {provider}"
238
+ f"无法写入 {provider}(exit={completed.returncode});请检查密钥环服务及解锁状态。"
135
239
  )
136
240
  return str(provider)
137
241
 
138
242
 
139
243
  def delete_openapi_credentials() -> tuple[bool, str | None]:
140
244
  provider = secure_store_name()
245
+ if provider == "Windows Credential Manager":
246
+ return bool(_windows_credential("delete")), provider
141
247
  if provider == "macOS Keychain":
142
248
  command = ["security", "delete-generic-password", "-s", SERVICE]
143
249
  elif provider == "Secret Service":
144
250
  command = ["secret-tool", "clear", "service", SERVICE]
145
251
  else:
146
252
  return False, None
147
- completed = subprocess.run(
253
+ completed = _credential_command(
148
254
  command,
149
255
  stdout=subprocess.DEVNULL,
150
256
  stderr=subprocess.PIPE,
@@ -156,5 +262,5 @@ def delete_openapi_credentials() -> tuple[bool, str | None]:
156
262
  if load_secure_credentials() is None:
157
263
  return False, provider
158
264
  raise CredentialError(
159
- completed.stderr.strip() or f"failed to clear {provider}"
265
+ f"无法清除 {provider}(exit={completed.returncode}),请检查解锁状态。"
160
266
  )
@@ -14,7 +14,7 @@ from urllib.parse import urlencode, urlsplit, urlunsplit
14
14
  from urllib.request import Request, urlopen
15
15
 
16
16
  from scnet_backends.base import BackendContext, BackendError
17
- from scnet_credentials import load_openapi_credentials
17
+ from scnet_credentials import CredentialError, load_openapi_credentials
18
18
  from scnet_version import VERSION
19
19
 
20
20
 
@@ -215,16 +215,13 @@ class SCNetClient:
215
215
  ]
216
216
  return self._regions_cache
217
217
 
218
- credentials, _ = load_openapi_credentials()
219
- user = self.env("SCNET_OPENAPI_USER") or (
220
- credentials.get("user") if credentials else None
221
- )
222
- access_key = self.env("SCNET_OPENAPI_ACCESS_KEY") or (
223
- credentials.get("access_key") if credentials else None
224
- )
225
- secret_key = self.env("SCNET_OPENAPI_SECRET_KEY") or (
226
- credentials.get("secret_key") if credentials else None
227
- )
218
+ try:
219
+ credentials, _ = load_openapi_credentials()
220
+ except CredentialError as exc:
221
+ raise BackendError(str(exc)) from None
222
+ user = credentials.get("user") if credentials else None
223
+ access_key = credentials.get("access_key") if credentials else None
224
+ secret_key = credentials.get("secret_key") if credentials else None
228
225
  missing = [
229
226
  name
230
227
  for name, value in (
@@ -48,6 +48,7 @@ if [ -z "$USER_NAME" ] && [ -n "${KEY_NAME_MARKER:-}" ]; then
48
48
  esac
49
49
  fi
50
50
  [ -n "$USER_NAME" ] || die "无法从文件名推断用户名,请作为最后一个参数传入"
51
+ [[ "$USER_NAME" =~ ^[A-Za-z0-9._-]+$ ]] || die "远端用户名包含空白或不安全字符"
51
52
 
52
53
  echo "==> 集群: ${CLUSTER_ID} (${CLUSTER_DESC:-$SSH_HOST})"
53
54
  echo "==> 用户名: ${USER_NAME}"
@@ -75,12 +76,12 @@ if [ "$(uname -s)" = "Darwin" ]; then
75
76
  fi
76
77
  info "$KEY_DST (600)"
77
78
 
78
- if ssh-keygen -y -f "$KEY_DST" > "$KEY_DST.pub" 2>/dev/null; then
79
+ if ssh-keygen -y -P '' -f "$KEY_DST" > "$KEY_DST.pub" 2>/dev/null; then
79
80
  chmod 644 "$KEY_DST.pub"
80
81
  info "$(ssh-keygen -lf "$KEY_DST" 2>/dev/null | awk '{print $1, $2, $4}')"
81
82
  else
82
83
  rm -f "$KEY_DST.pub"
83
- info "私钥有 passphrase,跳过公钥推导"
84
+ info "无法用空口令解析私钥:如有口令,请先运行 ssh-add;如未设置口令,请检查文件是否损坏。"
84
85
  fi
85
86
 
86
87
  # ---------------------------------------------------------------- config
@@ -166,6 +167,9 @@ EOF
166
167
  fi
167
168
 
168
169
  # ---------------------------------------------------------------- 验证
170
+ echo "==> 实际生效的 SSH 参数(来自 ssh -G;不显示私钥内容)"
171
+ ssh -G "$CLUSTER_ID" 2>/dev/null \
172
+ | awk '$1 == "hostname" || $1 == "user" || $1 == "port" || $1 == "identityfile" { print " " $0 }'
169
173
  echo "==> 测试连接"
170
174
  if out=$(ssh -o BatchMode=yes -o ConnectTimeout=20 \
171
175
  -o StrictHostKeyChecking=accept-new \
@@ -365,6 +365,11 @@ case "$(uname -s)" in
365
365
  printf '%s\n' "Linux:使用 ↑/↓ 和 Enter,也可输入编号;Ctrl+C 可取消。" >&2
366
366
  fi
367
367
  ;;
368
+ MINGW*|MSYS*|CYGWIN*)
369
+ printf '%s\n' \
370
+ "Windows:OpenAPI 可在 PowerShell 中运行 python scripts/scnet.py setup,并使用系统凭据管理器。" \
371
+ "SSH 自动安装请在 WSL2 内运行;WSL2 与 Windows OpenSSH 的私钥目录互相独立。" >&2
372
+ ;;
368
373
  esac
369
374
  printf '\n' >&2
370
375
 
@@ -478,8 +483,16 @@ if [ "$BACKEND" = both ]; then
478
483
  fi
479
484
 
480
485
  if [ "$BACKEND" = ssh ] || [ "$BACKEND" = both ]; then
486
+ printf '%s\n' \
487
+ "SSH 使用从目标集群控制台下载的私钥文件,不是 AK/SK 授权码,也不是 .pub 公钥。" \
488
+ "用户名应为集群/Linux 用户名;输入私钥文件路径,不要粘贴文件正文。" \
489
+ "将安装到 $HOME/.ssh/id_rsa_${CLUSTER_ID},配置 $HOME/.ssh/config;不同旧私钥会备份。" \
490
+ "已有非本工具管理的 Host 块会保留;口令私钥请先 ssh-add,过期密钥需重新下载。" >&2
481
491
  if [ "$SKIP_CONNECT" = no ]; then
482
492
  SSH_KEY="${SSH_KEY:-$(ask "私钥文件路径")}"
493
+ case "$SSH_KEY" in
494
+ \"*\"|\'*\') SSH_KEY="${SSH_KEY:1:${#SSH_KEY}-2}" ;;
495
+ esac
483
496
  SSH_KEY="${SSH_KEY/#\~/$HOME}"
484
497
  [ -f "$SSH_KEY" ] || die "找不到私钥文件: $SSH_KEY"
485
498
  fi