@elinpf/dsh-ops-access-ssh 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ title: "@elinpf/dsh-ops-access-ssh"
2
+ description:
3
+ zh: "ops access 接缝的 SSH 凭据 provider — 校验 ssh registry 条目并展开密钥路径"
4
+ en: "SSH credential provider for the ops access seam — validates ssh registry entries and expands key paths"
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @elinpf/dsh-ops-access-ssh
2
+
3
+ SSH credential provider for the ops access seam — validates `ssh` registry entries (`{ host, user, key?, port? }`) and expands key paths for the ssh consumer tool.
4
+
5
+ ## What it does
6
+
7
+ One of three provider plugins behind `@elinpf/dsh-ops-access` (core). Core owns the YAML credential registry (`~/.dsh-ops/access.yaml`) and the `ctx.opsAccess` service; this package contributes exactly one credential kind, `ssh`, via `registerAccessProvider`:
8
+
9
+ - **Entry schema** (zod): `host`, `user`, optional `key` (private-key path), optional `port`
10
+ - **Field processing**: expands `~` in the key path so `ssh -i` sees an absolute path
11
+ - **Save-time key validation** in two layers: a cheap PEM armor check, then a REAL parse via `ssh-keygen -y` — a structurally plausible but corrupt paste fails with a clear message instead of `error in libcrypto` mid-investigation, and a passphrase-protected key gets its BatchMode explanation up front
12
+ - **Pasted key content**: when the admin UI receives key CONTENT (not a path), core writes it to a managed file under `~/.dsh-ops/credentials/`; the provider opts into trailing-newline normalization (a paste that lost the final newline of the END line failed in libcrypto at first use — 2026-08-27)
13
+
14
+ ## Why this shape
15
+
16
+ The three-role rule (see the repo `AGENTS.md`): core owns the registry and the service, providers carry only a zod schema plus field processing, consumer tools resolve a profile and build the shell command. Keeping this package to schema + processing means no secret material ever passes through any service — profiles carry only paths and connection parameters.
17
+
18
+ Registration defers through `ctx.inject(['opsAccess'], ...)` inside `registerAccessProvider` and is tied to the plugin's effect lifecycle, so fiber disposal / HMR unload removes the provider from the registry.
19
+
20
+ ## Installation
21
+
22
+ Add to `dsh-web-app` dependencies and reference in the ops preset's `agent.cordis.yml`:
23
+
24
+ ```yaml
25
+ - id: ops-access-ssh
26
+ name: '@elinpf/dsh-ops-access-ssh'
27
+ ```
28
+
29
+ ## Configuration
30
+
31
+ - `validateTimeoutMs` (number, default `5000`) — timeout for the save-time `ssh-keygen -y` parse.
32
+
33
+ ## Testing
34
+
35
+ ```sh
36
+ npm run build
37
+ npx vitest run
38
+ ```
39
+
40
+ The spec covers schema accept/reject, `~` expansion, registration/disposal through a mock `opsAccess` context (including HMR unload), and the validateContent armor gate + real `ssh-keygen` parse (it generates throwaway ed25519 keys in a tmp dir).
package/README.zh.md ADDED
@@ -0,0 +1,40 @@
1
+ # @elinpf/dsh-ops-access-ssh
2
+
3
+ ops access 接缝的 SSH 凭据 provider — 校验 `ssh` registry 条目(`{ host, user, key?, port? }`)并为 ssh 消费端工具展开密钥路径。
4
+
5
+ ## 功能
6
+
7
+ `@elinpf/dsh-ops-access`(core) 下的三个 provider 插件之一。core 拥有 YAML 凭据 registry(`~/.dsh-ops/access.yaml`)和 `ctx.opsAccess` 服务;本包通过 `registerAccessProvider` 只贡献一个凭据种类 `ssh`:
8
+
9
+ - **条目 schema**(zod):`host`、`user`,可选 `key`(私钥路径),可选 `port`
10
+ - **字段处理**:展开 key 路径中的 `~`,让 `ssh -i` 拿到绝对路径
11
+ - **保存时密钥校验**分两层:先做廉价的 PEM armor 检查,再用 `ssh-keygen -y` 做真实解析 — 结构看似合法但内容损坏的粘贴会在此刻得到清晰报错,而不是在排查中途撞上 `error in libcrypto`;带口令的密钥也会提前得到 BatchMode 说明
12
+ - **粘贴密钥内容**:admin UI 收到的是密钥内容(而非路径)时,core 会写入 `~/.dsh-ops/credentials/` 下的受管文件;provider 声明尾随换行规范化(粘贴恰好丢了 END 行末尾换行时,首次使用会在 libcrypto 里失败 — 2026-08-27)
13
+
14
+ ## 为什么这样拆
15
+
16
+ 三角色规则(见仓库 `AGENTS.md`):core 拥有 registry 和服务,provider 只带一个 zod schema 加字段处理,消费端工具解析 profile 并拼 shell 命令。本包保持 schema + 处理的最小形态,意味着任何服务都不经手秘密材料 — profile 里只有路径和连接参数。
17
+
18
+ 注册在 `registerAccessProvider` 内部经 `ctx.inject(['opsAccess'], ...)` 延迟挂载,并绑定在插件的 effect 生命周期上,fiber 销毁 / HMR 卸载会把 provider 从 registry 移除。
19
+
20
+ ## 安装
21
+
22
+ 加入 `dsh-web-app` 依赖,并在 ops preset 的 `agent.cordis.yml` 中引用:
23
+
24
+ ```yaml
25
+ - id: ops-access-ssh
26
+ name: '@elinpf/dsh-ops-access-ssh'
27
+ ```
28
+
29
+ ## 配置项
30
+
31
+ - `validateTimeoutMs`(数字,默认 `5000`)— 保存时 `ssh-keygen -y` 解析的超时时间。
32
+
33
+ ## 测试
34
+
35
+ ```sh
36
+ npm run build
37
+ npx vitest run
38
+ ```
39
+
40
+ 测试覆盖:schema 接受/拒绝、`~` 展开、经 mock `opsAccess` 上下文的注册/销毁(含 HMR 卸载),以及 validateContent 的 armor 闸门 + 真实 `ssh-keygen` 解析(在临时目录生成一次性 ed25519 密钥)。
@@ -0,0 +1 @@
1
+ []
package/lib/index.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Ops access provider for SSH.
3
+ *
4
+ * Validates `ssh` registry entries (`{ host, user, key?, port? }`). When the
5
+ * admin UI receives private-key CONTENT (instead of a path), core writes it
6
+ * to a managed file under ~/.dsh-ops/credentials/ and stores the path in the
7
+ * registry. The provider expands ~ in the path for ssh -i. Pasted key
8
+ * content is normalized to end with exactly one newline and deep-parsed at
9
+ * save time (ssh-keygen -y) — both classic paste losses (2026-08-27).
10
+ *
11
+ * @module @elinpf/dsh-ops-access-ssh
12
+ */
13
+ import type { Context } from '@deepseek-ai/cordis';
14
+ import z from '@deepseek-ai/schemastery';
15
+ import { z as zod } from 'zod';
16
+ import type { AccessProvider } from '@elinpf/dsh-ops-access';
17
+ import type { SshProviderConfig } from './types.js';
18
+ export type { SshEntry, SshProviderConfig } from './types.js';
19
+ export declare const name = "ops-access-ssh";
20
+ export declare const inject: string[];
21
+ export declare const Config: z<Schemastery.ObjectS<{
22
+ /** Save-time validation: timeout for the `ssh-keygen -y` parse (ms). */
23
+ validateTimeoutMs: z<number, number>;
24
+ }>, Schemastery.ObjectT<{
25
+ /** Save-time validation: timeout for the `ssh-keygen -y` parse (ms). */
26
+ validateTimeoutMs: z<number, number>;
27
+ }>>;
28
+ /** Zod schema for one ssh registry entry (excluding name and envelope fields). */
29
+ export declare const entrySchema: zod.ZodObject<{
30
+ host: zod.ZodString;
31
+ user: zod.ZodString;
32
+ key: zod.ZodOptional<zod.ZodString>;
33
+ port: zod.ZodOptional<zod.ZodNumber>;
34
+ }, zod.core.$strip>;
35
+ export declare const provider: AccessProvider;
36
+ export declare function apply(ctx: Context, config: SshProviderConfig): void;
package/lib/index.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Ops access provider for SSH.
3
+ *
4
+ * Validates `ssh` registry entries (`{ host, user, key?, port? }`). When the
5
+ * admin UI receives private-key CONTENT (instead of a path), core writes it
6
+ * to a managed file under ~/.dsh-ops/credentials/ and stores the path in the
7
+ * registry. The provider expands ~ in the path for ssh -i. Pasted key
8
+ * content is normalized to end with exactly one newline and deep-parsed at
9
+ * save time (ssh-keygen -y) — both classic paste losses (2026-08-27).
10
+ *
11
+ * @module @elinpf/dsh-ops-access-ssh
12
+ */
13
+ import z from '@deepseek-ai/schemastery';
14
+ import { z as zod } from 'zod';
15
+ import { expandHome, registerAccessProvider } from '@elinpf/dsh-ops-access';
16
+ import { execFile } from 'node:child_process';
17
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { promisify } from 'node:util';
21
+ const execFileAsync = promisify(execFile);
22
+ // ── Plugin identity ───────────────────────────────────────────────────────────
23
+ export const name = 'ops-access-ssh';
24
+ export const inject = [];
25
+ export const Config = z.object({
26
+ /** Save-time validation: timeout for the `ssh-keygen -y` parse (ms). */
27
+ validateTimeoutMs: z.number().default(5000),
28
+ });
29
+ // ── Provider ─────────────────────────────────────────────────────────────────
30
+ /** Zod schema for one ssh registry entry (excluding name and envelope fields). */
31
+ export const entrySchema = zod.object({
32
+ host: zod.string(),
33
+ user: zod.string(),
34
+ key: zod.string().optional(),
35
+ port: zod.number().optional(),
36
+ });
37
+ // Save-time guard in two layers: a cheap armor check, then a REAL parse —
38
+ // ssh-keygen -y derives the public key with the same parser ssh runs at
39
+ // connection time. A structurally plausible but corrupt paste fails here
40
+ // with a clear message instead of 'error in libcrypto' mid-investigation,
41
+ // and a passphrase-protected key gets its BatchMode explanation up front.
42
+ async function validateKeyContent(field, content, keygenTimeoutMs) {
43
+ if (field !== 'key')
44
+ return null;
45
+ if (!/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(content) || !/-----END [A-Z0-9 ]*PRIVATE KEY-----/.test(content)) {
46
+ return 'not a private key — expected a -----BEGIN ... PRIVATE KEY----- block (paste the full key file)';
47
+ }
48
+ const dir = await mkdtemp(join(tmpdir(), 'ops-ssh-key-'));
49
+ try {
50
+ const keyPath = join(dir, 'key');
51
+ await writeFile(keyPath, content, { mode: 0o600 });
52
+ try {
53
+ await execFileAsync('ssh-keygen', ['-y', '-P', '', '-f', keyPath], { timeout: keygenTimeoutMs });
54
+ return null;
55
+ }
56
+ catch (err) {
57
+ const e = err;
58
+ // No ssh-keygen on this host: fall back to the armor gate above —
59
+ // the deep parse is a bonus, not a new hard dependency.
60
+ if (e.code === 'ENOENT')
61
+ return null;
62
+ const stderr = typeof e.stderr === 'string' && e.stderr.length > 0 ? e.stderr : String(e.message ?? err);
63
+ if (/passphrase/i.test(stderr) || content.includes('ENCRYPTED')) {
64
+ return 'the private key is passphrase-protected — the ssh tool runs BatchMode=yes and cannot answer prompts; remove the passphrase first (ssh-keygen -p)';
65
+ }
66
+ const detail = stderr.split('\n').map((l) => l.trim()).filter((l) => l.length > 0).pop() ?? 'unknown parse error';
67
+ return 'ssh-keygen cannot parse this key (' + detail + ') — the paste is corrupt; re-copy the key file verbatim';
68
+ }
69
+ }
70
+ finally {
71
+ await rm(dir, { recursive: true, force: true });
72
+ }
73
+ }
74
+ export const provider = {
75
+ kind: 'ssh',
76
+ schema: entrySchema,
77
+ fieldsDoc: 'host: hostname or IP; user: login user; key: optional private-key content; port: optional, default 22',
78
+ fileFields: ['key'],
79
+ derivationDoc: 'ssh has no read-only shell — the credential lives in the ro tier and every use is grant-gated; to provision a dedicated key during an approved session: generate a fresh keypair (ssh-keygen -t ed25519), append the public key to the target user\'s authorized_keys, then register the private key via register_access',
80
+ process(entry) {
81
+ const { host, user, key, port } = entry;
82
+ const fields = { host, user };
83
+ if (key !== undefined)
84
+ fields.key = expandHome(key);
85
+ if (port !== undefined)
86
+ fields.port = port;
87
+ return fields;
88
+ },
89
+ // Core normalizes the trailing newline before this hook (PEM requires
90
+ // the END line newline-terminated; a paste that lost exactly that byte
91
+ // failed in libcrypto at first use — 2026-08-27).
92
+ normalizeTrailingNewline: true,
93
+ validateContent: (field, content) => validateKeyContent(field, content, 5000),
94
+ };
95
+ // ── Plugin apply ─────────────────────────────────────────────────────────────
96
+ export function apply(ctx, config) {
97
+ registerAccessProvider(ctx, {
98
+ ...provider,
99
+ validateContent: (field, content) => validateKeyContent(field, content, config.validateTimeoutMs),
100
+ });
101
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Invariant companion for @elinpf/dsh-ops-access-ssh.
3
+ *
4
+ * @module @elinpf/dsh-ops-access-ssh/invariant
5
+ */
6
+ /** Cordis companion plugin name. */
7
+ declare const name = "ops-access-ssh-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Context carrying the invariant service.
13
+ * @returns a promise resolving after registration.
14
+ */
15
+ declare const apply: (ctx: any) => Promise<void>;
16
+ export { apply, inject, name };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Invariant companion for @elinpf/dsh-ops-access-ssh.
3
+ *
4
+ * @module @elinpf/dsh-ops-access-ssh/invariant
5
+ */
6
+ const PACKAGE_NAME = '@elinpf/dsh-ops-access-ssh';
7
+ /** Cordis companion plugin name. */
8
+ const name = 'ops-access-ssh-invariant';
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ['invariants'];
11
+ /**
12
+ * No runtime invariant: this provider owns no session events and no durable
13
+ * shape of its own — it only contributes a zod schema plus field processing
14
+ * to the ops-access registry, and entry validation happens at save time in
15
+ * core (schema parse + validateContent), before anything is persisted.
16
+ */
17
+ const install = () => { };
18
+ /**
19
+ * Register this package's invariant companion.
20
+ * @param ctx - Context carrying the invariant service.
21
+ * @returns a promise resolving after registration.
22
+ */
23
+ const apply = async (ctx) => {
24
+ ctx.invariants.register(PACKAGE_NAME, install);
25
+ };
26
+ export { apply, inject, name };
package/lib/types.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Type definitions for the ops-access SSH provider.
3
+ *
4
+ * @module @elinpf/dsh-ops-access-ssh
5
+ */
6
+ /**
7
+ * One ssh registry entry (excluding name and the envelope fields).
8
+ *
9
+ * This is the hand-written mirror of the runtime `entrySchema` in index.ts —
10
+ * keep the two in sync when fields change.
11
+ */
12
+ export interface SshEntry {
13
+ /** Hostname or IP of the target. */
14
+ host: string;
15
+ /** Login user. */
16
+ user: string;
17
+ /** Path to the private key file (`~` is expanded by the provider). */
18
+ key?: string;
19
+ /** SSH port (default 22). */
20
+ port?: number;
21
+ }
22
+ /** Plugin configuration (see `Config` in index.ts). */
23
+ export interface SshProviderConfig {
24
+ /** Save-time validation: timeout for the `ssh-keygen -y` parse (ms). */
25
+ validateTimeoutMs: number;
26
+ }
package/lib/types.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Type definitions for the ops-access SSH provider.
3
+ *
4
+ * @module @elinpf/dsh-ops-access-ssh
5
+ */
6
+ export {};
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@elinpf/dsh-ops-access-ssh",
3
+ "version": "0.1.0",
4
+ "description": "Ops access provider for SSH — validates ssh registry entries and expands key paths.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./invariant": {
14
+ "types": "./lib/invariant.d.ts",
15
+ "default": "./lib/invariant.js"
16
+ },
17
+ "./types": {
18
+ "types": "./lib/types.d.ts",
19
+ "default": "./lib/types.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "lib/index.js",
25
+ "lib/invariant.js",
26
+ "lib/types.js",
27
+ "lib/**/*.d.ts",
28
+ "cordis.patch.yml"
29
+ ],
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ }
34
+ },
35
+ "dependencies": {
36
+ "@deepseek-ai/schemastery": "^3.18.1",
37
+ "zod": "^4.4.3"
38
+ },
39
+ "peerDependencies": {
40
+ "@deepseek-ai/cordis": "^4.0.1",
41
+ "@elinpf/dsh-ops-access": "^0.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "@deepseek-ai/cordis": "4.0.1",
45
+ "@types/node": "^22.0.0",
46
+ "typescript": "^5.4.0",
47
+ "vitest": "^4.1.11",
48
+ "@elinpf/dsh-ops-access": "0.1.0"
49
+ },
50
+ "license": "MIT",
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "scripts": {
55
+ "build": "tsc",
56
+ "typecheck": "tsc --noEmit",
57
+ "test": "vitest run"
58
+ }
59
+ }