dsh-over-ssh 0.0.0-stage → 0.1.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -2
  3. package/agents/agent-manifest.json +20 -0
  4. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  5. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  6. package/cordis.patch.yml +84 -0
  7. package/lib/client.js +6660 -0
  8. package/lib/file-references.js +411 -0
  9. package/lib/fs.js +1451 -0
  10. package/lib/index.js +19740 -0
  11. package/lib/local-bridge-refs.js +16 -0
  12. package/lib/local-bridge.js +16 -0
  13. package/lib/terminal-wrapper.js +299 -0
  14. package/lib/types/src/client/index.d.ts +49 -0
  15. package/lib/types/src/config.d.ts +383 -0
  16. package/lib/types/src/file-references.d.ts +24 -0
  17. package/lib/types/src/host/app/backend.d.ts +178 -0
  18. package/lib/types/src/host/app/recycle-bin/recycle-bin-contract.d.ts +246 -0
  19. package/lib/types/src/host/app/recycle-bin/recycle-bin-service.d.ts +44 -0
  20. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +232 -0
  21. package/lib/types/src/host/app/session-admin/session-admin-service.d.ts +39 -0
  22. package/lib/types/src/host/app/transport/transport-selector.d.ts +97 -0
  23. package/lib/types/src/host/app/transport/transport-service.d.ts +81 -0
  24. package/lib/types/src/host/domain/capability.d.ts +77 -0
  25. package/lib/types/src/host/domain/errors.d.ts +54 -0
  26. package/lib/types/src/host/domain/roots/connection.d.ts +105 -0
  27. package/lib/types/src/host/domain/roots/root.d.ts +123 -0
  28. package/lib/types/src/host/domain/roots/session-roots.d.ts +150 -0
  29. package/lib/types/src/host/domain/roots/ssh-config.d.ts +72 -0
  30. package/lib/types/src/host/domain/session/session-admin.d.ts +344 -0
  31. package/lib/types/src/host/domain/transfer/recycle-bin.d.ts +336 -0
  32. package/lib/types/src/host/domain/transfer/transfer.d.ts +259 -0
  33. package/lib/types/src/host/infra/transport/agent/agent-installer.d.ts +120 -0
  34. package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +327 -0
  35. package/lib/types/src/host/infra/transport/transport-base.d.ts +33 -0
  36. package/lib/types/src/host/infra/transport/transport.d.ts +541 -0
  37. package/lib/types/src/host/integration/agent/agent-preflight.d.ts +55 -0
  38. package/lib/types/src/host/integration/fs/file-reference-provider.d.ts +141 -0
  39. package/lib/types/src/host/integration/fs/fs-provider.d.ts +366 -0
  40. package/lib/types/src/host/integration/transfer/transfer-service.d.ts +211 -0
  41. package/lib/types/src/index.d.ts +88 -0
  42. package/lib/types/src/local-bridge-refs.d.ts +25 -0
  43. package/lib/types/src/local-bridge.d.ts +35 -0
  44. package/lib/types/src/shared/format.d.ts +13 -0
  45. package/lib/types/src/shared/root-keys.d.ts +120 -0
  46. package/lib/types/src/wiring/helpers.d.ts +143 -0
  47. package/package.json +122 -4
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Transport lifecycle: acquire, reuse, replace and release one transport per connection.
3
+ *
4
+ * The service owns three behaviours the rest of the plugin relies on:
5
+ *
6
+ * 1. **One transport per connection**, created lazily and shared by every caller (single flight).
7
+ * 2. **Replacement on a changed decision**: when the selector's answer changes (for example after
8
+ * a mux failure is recorded), the old transport is disposed and a new one built, so a stale
9
+ * decision can never keep serving traffic.
10
+ * 3. **Explicit release**, so shutting a connection down closes its process/connection rather
11
+ * than leaving it to the garbage collector.
12
+ *
13
+ * The transport contract lives in `infra/transport/transport.ts` and is imported as a type only,
14
+ * which keeps this module free of implementation and of the core packages.
15
+ *
16
+ * @module dsh-over-ssh/app/transport-service
17
+ */
18
+ import type { ConnectionProfile } from '../../domain/roots/connection.ts';
19
+ import type { Transport } from '../../infra/transport/transport.ts';
20
+ import type { TransportDecision } from './transport-selector.ts';
21
+ import type { TransportSelector } from './transport-selector.ts';
22
+ /** Creates the implementation chosen by the selector. */
23
+ export interface TransportFactories {
24
+ readonly createAlias: (profile: ConnectionProfile, decision: TransportDecision) => Transport;
25
+ readonly createSsh2: (profile: ConnectionProfile, decision: TransportDecision) => Transport;
26
+ /**
27
+ * Creates the Tier 2 agent transport. Optional: a build without the agent (or without a shipped binary)
28
+ * simply omits it, and the selector never chooses `agent` in that case.
29
+ */
30
+ readonly createAgent?: (profile: ConnectionProfile, decision: TransportDecision) => Transport;
31
+ }
32
+ /**
33
+ * Optional asynchronous step between deciding and connecting.
34
+ *
35
+ * The synchronous selector cannot know whether the agent's install ladder succeeded — that requires
36
+ * talking to the host. A preflight therefore gets the first decision and may revise it (for example
37
+ * `agent` → `alias` once the ladder reported a reason), which is how "agent unavailable ⇒ fall back to
38
+ * Tier 1" is enforced *before* a transport is built.
39
+ */
40
+ /**
41
+ * Preflight hook: may replace the decision (e.g. "install the agent and use Tier 2 after all").
42
+ *
43
+ * `signal` is the caller's cancellation for the whole acquire. It must reach the hook: the ladder it runs
44
+ * (ssh connect, manifest load, `$HOME` probe, agent upload) can take minutes, and a cancelled call used to
45
+ * keep uploading anyway because only `transport.connect()` saw the signal.
46
+ */
47
+ export type TransportPreflight = (profile: ConnectionProfile, decision: TransportDecision, signal?: AbortSignal) => Promise<TransportDecision>;
48
+ /** One acquired transport plus the decision that produced it. */
49
+ export interface AcquiredTransport {
50
+ readonly transport: Transport;
51
+ readonly decision: TransportDecision;
52
+ }
53
+ /** Facts injected for testability. */
54
+ export interface TransportServiceOptions {
55
+ readonly selector: TransportSelector;
56
+ readonly factories: TransportFactories;
57
+ /** See {@link TransportPreflight}. Omitted means "no revision". */
58
+ readonly preflight?: TransportPreflight;
59
+ }
60
+ /** Owns one transport instance per connection. */
61
+ export declare class TransportService {
62
+ #private;
63
+ constructor(options: TransportServiceOptions);
64
+ /**
65
+ * Get a connected transport for a profile, creating it if needed.
66
+ *
67
+ * @param profile - the connection to serve.
68
+ * @param signal - cancellation for the connect attempt.
69
+ * @returns the transport and the decision behind it (after any preflight revision).
70
+ * @throws SshDevError from the selector or from the transport's own connect.
71
+ */
72
+ acquire(profile: ConnectionProfile, signal?: AbortSignal): Promise<AcquiredTransport>;
73
+ /** The cached transport for a connection, if any. */
74
+ current(connectionId: string): AcquiredTransport | undefined;
75
+ /** Decisions currently in force, for the connection status UI. */
76
+ decisions(): readonly TransportDecision[];
77
+ /** Dispose and forget one connection. */
78
+ release(connectionId: string): Promise<void>;
79
+ /** Dispose every connection (plugin unload, connection-service shutdown). */
80
+ disposeAll(): Promise<void>;
81
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Remote capability probing.
3
+ *
4
+ * The plugin installs nothing on the remote host, so it must discover what the host already
5
+ * offers and degrade accordingly: `git ls-files` when git exists, a bounded `find` otherwise;
6
+ * `rg` when available, bounded `grep` otherwise.
7
+ *
8
+ * @module dsh-over-ssh/domain/capability
9
+ */
10
+ /** What one probe learned about the remote host. */
11
+ export interface RemoteCapabilities {
12
+ /** `uname -s` output, or `unknown`. */
13
+ readonly uname: string;
14
+ /** Whether the host is a POSIX shell host this plugin supports. */
15
+ readonly posix: boolean;
16
+ readonly git: boolean;
17
+ readonly ripgrep: boolean;
18
+ readonly grep: boolean;
19
+ readonly base64: boolean;
20
+ readonly findMaxDepth: boolean;
21
+ readonly sha256: 'sha256sum' | 'shasum' | null;
22
+ readonly statFlavor: 'gnu' | 'bsd' | 'unknown';
23
+ /**
24
+ * Language servers found on the remote `PATH`.
25
+ *
26
+ * `undefined` means **nobody looked** (Tier 1 shell transport, or an agent older than the probe);
27
+ * an empty array means the probe ran and found none. The difference matters: the first case must not be
28
+ * reported to a caller as "not installed", because we have no evidence either way.
29
+ */
30
+ readonly lspServers?: readonly LspServerInfo[];
31
+ }
32
+ /** One language server found on the remote `PATH`. */
33
+ export interface LspServerInfo {
34
+ readonly command: string;
35
+ readonly language: string;
36
+ readonly path: string;
37
+ }
38
+ /**
39
+ * The one-shot probe script.
40
+ *
41
+ * Every line is `key=value`; values never contain newlines. The script is written for `sh`
42
+ * and tolerates missing commands, because discovering a missing command is the point.
43
+ *
44
+ * @returns a POSIX shell script.
45
+ */
46
+ export declare function probeScript(): string;
47
+ /** The capabilities assumed when probing failed: POSIX basics only. */
48
+ export declare function conservativeCapabilities(): RemoteCapabilities;
49
+ /**
50
+ * Parse probe output into capabilities.
51
+ *
52
+ * Unknown keys are ignored so the script can grow without breaking older parsers.
53
+ *
54
+ * @param stdout - the probe's standard output.
55
+ * @returns parsed capabilities.
56
+ */
57
+ export declare function parseProbeOutput(stdout: string): RemoteCapabilities;
58
+ /** How the plugin will search file contents on a host. */
59
+ export type SearchStrategy = {
60
+ readonly kind: 'git-grep';
61
+ } | {
62
+ readonly kind: 'ripgrep';
63
+ } | {
64
+ readonly kind: 'grep-bounded';
65
+ };
66
+ /** How the plugin will enumerate files for `@` completion on a host. */
67
+ export type IndexStrategy = {
68
+ readonly kind: 'git-ls-files';
69
+ } | {
70
+ readonly kind: 'find-bounded';
71
+ };
72
+ /** Pick the content-search strategy for a host. */
73
+ export declare function searchStrategyFor(capabilities: RemoteCapabilities): SearchStrategy;
74
+ /** Pick the file-enumeration strategy for a host. */
75
+ export declare function indexStrategyFor(capabilities: RemoteCapabilities): IndexStrategy;
76
+ /** Whether the host can run the plugin's file-transfer path. */
77
+ export declare function canTransferBytes(capabilities: RemoteCapabilities): boolean;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Domain errors and SSH failure diagnostics.
3
+ *
4
+ * Failure classification satisfies the requirement that a failed connection must name
5
+ * an actionable cause (missing key vs rejected key vs unreachable host vs changed host key)
6
+ * instead of a generic "connection failed".
7
+ *
8
+ * @module dsh-over-ssh/domain/errors
9
+ */
10
+ /** Stable error codes crossing the plugin's internal and remote boundaries. */
11
+ export declare const SSH_DEV_ERROR_CODES: readonly ["CONNECTION_INVALID", "CONNECTION_NOT_FOUND", "CONNECTION_LOST", "CONNECTION_UNREACHABLE", "AUTH_FAILED", "HOST_KEY_CHANGED", "ROOT_INVALID", "ROOT_AMBIGUOUS", "ROOT_OUTSIDE", "REMOTE_NOT_FOUND", "REMOTE_UNSUPPORTED", "REMOTE_COMMAND_FAILED", "SANDBOX_DENIED", "TRANSFER_CONFLICT", "TRASH_NOT_FOUND", "SESSION_ACTIVE", "CANCELLED"];
12
+ /** One stable plugin error code. */
13
+ export type SshDevErrorCode = (typeof SSH_DEV_ERROR_CODES)[number];
14
+ /** A plugin-owned failure that carries a stable code and structured details. */
15
+ export declare class SshDevError extends Error {
16
+ readonly code: SshDevErrorCode;
17
+ readonly details: Readonly<Record<string, unknown>>;
18
+ constructor(code: SshDevErrorCode, message: string, details?: Readonly<Record<string, unknown>>);
19
+ }
20
+ /** Test whether a value is a plugin-owned failure. */
21
+ export declare function isSshDevError(value: unknown): value is SshDevError;
22
+ /** Actionable classification of one local `ssh` failure. */
23
+ export type SshFailureKind = 'auth-failed' | 'key-missing' | 'key-permissions' | 'agent-unavailable' | 'host-unreachable' | 'dns-failed' | 'host-key-changed' | 'host-key-unknown' | 'timeout' | 'remote-command-missing' | 'connection-lost' | 'unknown';
24
+ /** A classified failure plus the next action a user can take. */
25
+ export interface SshDiagnostic {
26
+ readonly kind: SshFailureKind;
27
+ readonly hint: string;
28
+ }
29
+ /** Classify one local `ssh` attempt from its exit status, stderr and signal. */
30
+ export declare function classifySshFailure(input: {
31
+ readonly exitCode?: number | undefined;
32
+ readonly stderr?: string | undefined;
33
+ readonly signal?: string | null | undefined;
34
+ }): SshDiagnostic;
35
+ /**
36
+ * Build the plugin failure for one unclassified-or-classified ssh failure.
37
+ *
38
+ * The classification is what decides the **code**, and the two host-key kinds have codes of their own. Mapping
39
+ * them onto `CONNECTION_LOST` (the old fallback) told the user "the connection dropped, reconnect" for a
40
+ * condition where reconnecting is exactly what must not happen silently — a changed host key is the one
41
+ * failure whose whole point is to stop and be looked at. `HOST_KEY_CHANGED` was declared in
42
+ * {@link SSH_DEV_ERROR_CODES} and never thrown anywhere until this mapping existed (audit 0043).
43
+ *
44
+ * @param connectionId - the connection the attempt belonged to.
45
+ * @param input - exit status, stderr and signal of the failed attempt.
46
+ * @returns the error to surface.
47
+ */
48
+ export declare function sshFailureToError(connectionId: string, input: {
49
+ readonly exitCode?: number | undefined;
50
+ readonly stderr?: string | undefined;
51
+ readonly signal?: string | null | undefined;
52
+ }): SshDevError;
53
+ /** Keep only the tail of a long diagnostic string so details stay bounded. */
54
+ export declare function tail(text: string, maxChars: number): string;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Connection profiles and their validation.
3
+ *
4
+ * A profile never carries secret material: a password is referenced by credential key and
5
+ * resolved from `ctx.credentials` per operation.
6
+ *
7
+ * @module dsh-over-ssh/domain/connection
8
+ */
9
+ import type { SshConfigImportDraft } from './ssh-config.ts';
10
+ /** How the transport reaches the host. `agent` is the opt-in Tier 2 proxy (launched over the system ssh). */
11
+ export type TransportKind = 'alias' | 'ssh2' | 'agent';
12
+ /** Host key verification policy. */
13
+ export type HostKeyPolicy = 'strict' | 'accept-new';
14
+ /** Authentication method. */
15
+ export type AuthSpec = {
16
+ readonly kind: 'agent';
17
+ } | {
18
+ readonly kind: 'key';
19
+ readonly keyPath: string;
20
+ readonly hasPassphrase: boolean;
21
+ } | {
22
+ readonly kind: 'password';
23
+ readonly credentialKey: string;
24
+ };
25
+ /** One saved connection. */
26
+ export interface ConnectionProfile {
27
+ /** Stable slug used inside `ssh://` paths and as the pool key. */
28
+ readonly id: string;
29
+ /** Human-facing name. */
30
+ readonly name: string;
31
+ /** Transport implementation. */
32
+ readonly transport: TransportKind;
33
+ /** Host name used by the ssh2 transport, and by alias transport only for diagnostics. */
34
+ readonly host: string;
35
+ readonly port: number;
36
+ readonly user: string;
37
+ /** OpenSSH alias; required by the alias transport. */
38
+ readonly alias?: string;
39
+ readonly auth: AuthSpec;
40
+ /** ProxyJump chain, applied through `-J` (ssh2) or the alias (config). */
41
+ readonly proxyJump: readonly string[];
42
+ readonly hostKeyPolicy: HostKeyPolicy;
43
+ /** Remote directory used as the default root when creating a remote workspace. */
44
+ readonly remoteDefaultRoot?: string;
45
+ readonly connectTimeoutMs: number;
46
+ readonly commandTimeoutMs: number;
47
+ readonly keepAliveSeconds: number;
48
+ }
49
+ /** Defaults for a new connection. */
50
+ export declare const CONNECTION_DEFAULTS: {
51
+ readonly port: 22;
52
+ readonly connectTimeoutMs: 15000;
53
+ readonly commandTimeoutMs: 30000;
54
+ readonly keepAliveSeconds: 60;
55
+ };
56
+ /** Whether a string can name a value in the credential store. */
57
+ export declare function isValidCredentialRef(value: string): boolean;
58
+ /**
59
+ * 一个凭据引用名**可能落在哪几个名字上**,按优先顺序。
60
+ *
61
+ * `credentialKey` 会落盘进 `user-roots.json`,所以老连接的引用名仍是 `DSH_SSH_…`,直接用就行。回退是
62
+ * 为了那些**没落盘**的早期记录:它们的引用名是运行时按 id 现推的,改名后会推出新前缀,不回退就取不到
63
+ * 已存的密码。反过来,新名字在前,所以迁移过的连接不会去读旧位置。
64
+ *
65
+ * @param key - 当前的引用名。
66
+ * @returns 一个或两个候选名字;不是本插件派生的名字时只有一个(原样)。
67
+ */
68
+ export declare function credentialRefCandidates(key: string): readonly string[];
69
+ /**
70
+ * Derive the credential-reference name that holds one connection's password.
71
+ *
72
+ * The platform names stored values with a POSIX identifier, so a user-chosen name — `腾讯云` included —
73
+ * cannot be used verbatim. The derivation keeps whatever ASCII is readable and then appends a 64-bit digest
74
+ * of the **exact** id: readable enough to find in the credentials file, and collision-free between two names
75
+ * that sanitise to the same slug (`a b` and `a_b`). It is a pure function of the id, so re-adding the same
76
+ * name reuses one stored secret instead of accumulating orphans.
77
+ *
78
+ * @param connectionId - the user's connection name.
79
+ * @returns a name satisfying {@link isValidCredentialRef}.
80
+ */
81
+ export declare function credentialRefFor(connectionId: string): string;
82
+ /**
83
+ * Validate untrusted input into a connection profile.
84
+ *
85
+ * Misconfiguration fails loudly at this boundary, so the rest of the plugin can treat a
86
+ * `ConnectionProfile` as trustworthy.
87
+ *
88
+ * @param input - candidate value (settings JSON, import draft or test fixture).
89
+ * @returns the validated profile.
90
+ * @throws SshDevError with code `CONNECTION_INVALID`.
91
+ */
92
+ export declare function validateConnectionProfile(input: unknown): ConnectionProfile;
93
+ /**
94
+ * Turn one importable `~/.ssh/config` alias into a profile.
95
+ *
96
+ * The alias transport inherits user/port/identity/proxy settings from the alias itself, so
97
+ * the draft's explicit values are kept only for display and for the ssh2 fallback.
98
+ *
99
+ * @param id - connection id assigned by the caller (keeps this function deterministic).
100
+ * @param draft - one draft from {@link importableEntries}.
101
+ * @returns the validated profile.
102
+ */
103
+ export declare function profileFromImportDraft(id: string, draft: SshConfigImportDraft): ConnectionProfile;
104
+ /** One-line summary used by diagnostics and the settings list. */
105
+ export declare function connectionSummary(profile: ConnectionProfile): string;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Root references: one project root is either a local directory or a remote directory on
3
+ * one connection. Remote roots additionally carry a local *anchor* directory, because DSH
4
+ * requires a workspace path that exists on the host filesystem.
5
+ *
6
+ * @module dsh-over-ssh/domain/root
7
+ */
8
+ import { isAbsoluteLocalPath, type RootKeyOptions, type RootLocation } from '../../../shared/root-keys.ts';
9
+ /** Where a root lives. The definition lives in `shared/root-keys.ts`, beside the key rule that needs it. */
10
+ export type { RootLocation };
11
+ /** One project root. */
12
+ export interface RootRef {
13
+ /** Local or remote. */
14
+ readonly location: RootLocation;
15
+ /** Connection id; present exactly for remote roots. */
16
+ readonly connectionId?: string;
17
+ /** Absolute local path, or absolute POSIX path for a remote root. */
18
+ readonly path: string;
19
+ /** Whether the sandbox fence admits writes under this root. */
20
+ readonly writable: boolean;
21
+ /** Local anchor directory for a remote root (empty directory, never mirrored content). */
22
+ readonly anchorPath?: string;
23
+ /** Display label; defaults to the final path segment. */
24
+ readonly label?: string;
25
+ /**
26
+ * The workspaces (session cwd paths) that added this root.
27
+ *
28
+ * **Absent means "no session added it"**, which under session isolation means "no session may use it"
29
+ * ({@link rootVisibleToSession} in `./session-roots.ts`). It is not a default-allow: the user-roots file has
30
+ * carried this field since the 「添加目录」 grouping landed, and the settings panel writes unscoped roots on
31
+ * purpose (its form has no scope field).
32
+ */
33
+ readonly workspaces?: readonly string[];
34
+ /**
35
+ * The **sessions** (conversation ids) that added this root — the strict identity behind {@link workspaces}.
36
+ *
37
+ * `workspaces` answers "which workspace added it", which cannot separate two conversations opened on the
38
+ * same directory; this field can. When it is present **and** the caller carries a session id, the route
39
+ * table decides by session and never consults the cwd (ADR-0013, `domain/roots/session-roots.ts`). Absent
40
+ * means "written before ADR-0013" — such a root keeps the workspace-level rule, so existing data does not
41
+ * lose access on upgrade.
42
+ */
43
+ readonly sessions?: readonly string[];
44
+ /**
45
+ * Whether this root is **deployment-wide** rather than a session artifact.
46
+ *
47
+ * True for roots declared in the plugin's `config.roots`: they are infrastructure the deployment declares once
48
+ * (the stage-2 requirement "a local root covering the current workspace" is one of them), so every session may
49
+ * use them. False/absent for roots the user added through the panel or 「添加目录」 — those are per-session and
50
+ * are gated by {@link workspaces} (see `rootVisibleToSession`).
51
+ */
52
+ readonly shared?: boolean;
53
+ }
54
+ /** Options shared by both root kinds. */
55
+ export interface RootOptions {
56
+ /** Whether writes under this root are allowed; defaults to true. */
57
+ readonly writable?: boolean;
58
+ /** Display label override. */
59
+ readonly label?: string;
60
+ /** Workspaces that added this root; see {@link RootRef.workspaces}. */
61
+ readonly workspaces?: readonly string[];
62
+ /** Sessions that added this root; see {@link RootRef.sessions}. */
63
+ readonly sessions?: readonly string[];
64
+ /** Deployment-wide root; see {@link RootRef.shared}. */
65
+ readonly shared?: boolean;
66
+ }
67
+ /** Create a remote root, validating the connection id and remote path. */
68
+ export declare function makeRemoteRoot(connectionId: string, remotePath: string, options?: RootOptions & {
69
+ readonly anchorPath?: string;
70
+ }): RootRef;
71
+ /**
72
+ * Create a local root from an absolute host path.
73
+ *
74
+ * The stored path **preserves the caller's case** (only separators, `.`/`..` and the drive letter
75
+ * are canonicalised). Case folding belongs to comparison and keying — `rootKey` folds, and
76
+ * `localPathContains` folds on request — because the root's path is also what the UI shows and what
77
+ * a child process receives via `processPath`.
78
+ */
79
+ export declare function makeLocalRoot(path: string, options?: RootOptions): RootRef;
80
+ /**
81
+ * Whether a host path is absolute in either the Windows or the POSIX spelling.
82
+ *
83
+ * **Re-exported, not re-implemented.** The rule lives in `shared/root-keys.ts` because the client panel has to
84
+ * answer the same question before it sends a request — and the two answers had drifted: this file rejected
85
+ * `\\server\share` while the panel accepted it (审计 0112 P2-11). Both UNC spellings are accepted now, which is
86
+ * what this function always claimed in its one-line doc.
87
+ */
88
+ export { isAbsoluteLocalPath };
89
+ /**
90
+ * Normalize a host path for comparison: separators to `/`, `.`/`..` resolved, trailing
91
+ * separators dropped, and (optionally) the drive letter and case folded.
92
+ *
93
+ * The **implementation** is `shared/root-keys.ts` (the client's key needs the same normalisation). What stays
94
+ * here is the platform default: `caseInsensitive` defaults to "this host folds case on Windows", which is a
95
+ * fact only the host has — a browser bundle has no `process` and must not guess it.
96
+ *
97
+ * @param path - host path in either separator spelling.
98
+ * @param options - `caseInsensitive` folds case for comparison keys.
99
+ * @returns the normalized path.
100
+ */
101
+ export declare function normalizeLocalPath(path: string, options?: RootKeyOptions): string;
102
+ /** Whether a host path is `root` itself or a descendant of it. */
103
+ export declare function localPathContains(root: string, candidate: string, options?: {
104
+ readonly caseInsensitive?: boolean;
105
+ }): boolean;
106
+ /**
107
+ * Stable identity of a root, used as a cache/route key.
108
+ *
109
+ * The **spelling** is `shared/root-keys.ts`'s `rootKeySpelling`: the client rebuilds this same key for a row
110
+ * whose answer carried none, and a second spelling is what made the panel's delete button address a root the
111
+ * registry did not have (审计 0024 §8.3). Only the error policy and the platform's case fold stay here.
112
+ */
113
+ export declare function rootKey(root: RootRef): string;
114
+ /** The model-facing and UI-facing spelling of a root's directory. */
115
+ export declare function rootDisplay(root: RootRef): string;
116
+ /** The display label of a root: explicit label, else the final path segment. */
117
+ export declare function rootLabel(root: RootRef): string;
118
+ /** Whether two roots of the same location overlap (one contains the other). */
119
+ export declare function rootsOverlap(left: RootRef, right: RootRef): boolean;
120
+ /** Validate a root reference produced outside this module. */
121
+ export declare function validateRoot(root: RootRef): void;
122
+ /** FNV-1a (32-bit) over UTF-16 code units, rendered as 8 lowercase hex characters. */
123
+ export declare function fnv1aHex(input: string): string;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Session-scoped remote roots: which remote directories **this** conversation may target.
3
+ *
4
+ * The problem this solves. 「添加目录」 registers a directory for one session (the entry's `workspaces` list
5
+ * holds that session's workspace path), and until now that was a **visibility** rule only: the client card
6
+ * listed the directory, but every `remote_*` tool picked its root from the agent's session cwd alone
7
+ * (`remote-tools.ts` `targetFor`). A session opened on a local folder — `D:\project\x` — therefore could add
8
+ * `165服务器:/home1/me/work/android14`, see it in the card, and still get
9
+ * `工作目录不在任何远端根内:D:\project\x` from every remote tool. The user's own words:
10
+ * 「我一个本地目录的会话,我添加远程的路径,这个会话也能正常访问远程路径」.
11
+ *
12
+ * The rule implemented here is the narrow one that makes that work without turning 「添加目录」 into a
13
+ * process-wide switch:
14
+ *
15
+ * 1. A cwd that resolves to a remote root **wins** (the pre-existing behaviour, unchanged). A session living
16
+ * on a remote workspace keeps targeting it, and nothing about `ssh://` resolution changes.
17
+ * 2. Otherwise, the roots **this session added** are candidates. Exactly one candidate is used; zero is the
18
+ * old `ROOT_OUTSIDE` error; two or more is refused by name instead of guessed — the same stance the
19
+ * route table already takes for two remote roots with the same spelling (`ROOT_AMBIGUOUS`).
20
+ *
21
+ * Why "scoped to this session" was read from the **workspace path** rather than from a session id: that is
22
+ * what `add-root` wrote (`session-directory.ts` passes `workspaces: [state.workspacePath]`), and it was the
23
+ * only field the persisted file carried. **ADR-0013 changes that**: the platform does not hand a session id to
24
+ * `ctx.fs`, but it does hand one to every tool execution, and `integration/session-scope.ts` carries it down.
25
+ * Entries now also record `sessions`, and when both the root and the caller have one, **the session decides**
26
+ * and the workspace path is not consulted. A fork inherits its ancestors' entries ({@link SessionIdentity}).
27
+ * Entries written before ADR-0013 carry no `sessions` and keep the workspace-level rule unchanged.
28
+ *
29
+ * Pure: no filesystem, no transport, no platform types — the caller injects the entries. That is what makes
30
+ * the rule testable offline (红线 2/3).
31
+ *
32
+ * @module dsh-over-ssh/domain/roots/session-roots
33
+ */
34
+ import { type RootRef } from './root.ts';
35
+ /** One persisted root entry, as far as this module needs to read it. */
36
+ export interface SessionRootEntry {
37
+ readonly location: 'local' | 'remote';
38
+ readonly path: string;
39
+ readonly connectionId?: string;
40
+ /** The workspaces (session cwd paths) that added this root. Absent means "no session added it". */
41
+ readonly workspaces?: readonly string[];
42
+ /** The **sessions** (conversation ids) that added this root. Absent on entries written before ADR-0013. */
43
+ readonly sessions?: readonly string[];
44
+ }
45
+ /**
46
+ * Who is asking: the cwd every caller already carries, plus the session identity when one is available.
47
+ *
48
+ * `sessionId` is the strict identity — two conversations in the same workspace differ here and nowhere else.
49
+ * `ancestors` are that session's fork lineage, nearest first: a fork inherits the directories its parent added
50
+ * (the product decision recorded in ADR-0013), so a root listed for any ancestor is listed for the fork too.
51
+ * Both are optional because an agentless call (a CLI, a config replay) has neither.
52
+ */
53
+ export interface SessionIdentity {
54
+ /** The asking session's id, when one is known. */
55
+ readonly sessionId?: string | undefined;
56
+ /** The asking session's fork ancestors, nearest first. */
57
+ readonly ancestors?: readonly string[] | undefined;
58
+ }
59
+ /** The target shape the tools consume; kept structural so this module imports no integration code. */
60
+ export interface SessionRootTarget {
61
+ readonly connectionId: string;
62
+ readonly root: string;
63
+ }
64
+ /** Outcome of {@link resolveSessionRemoteRoot}. */
65
+ export type SessionRootOutcome = {
66
+ readonly ok: true;
67
+ readonly target: SessionRootTarget;
68
+ }
69
+ /** Nothing this session added is a remote root: the caller keeps its own error. */
70
+ | {
71
+ readonly ok: false;
72
+ readonly reason: 'none';
73
+ }
74
+ /** Several added remote roots: refused by name rather than guessed. */
75
+ | {
76
+ readonly ok: false;
77
+ readonly reason: 'ambiguous';
78
+ readonly candidates: readonly SessionRootTarget[];
79
+ };
80
+ /**
81
+ * Compare two host paths the way the persisted `workspaces` field is written.
82
+ *
83
+ * Windows paths are case-insensitive, and the file stores whatever the platform reported at add time while
84
+ * the session header may spell the same directory with different case. Folding here is what keeps a
85
+ * `D:\Project\X` session from losing the directory it added as `d:\project\x`.
86
+ *
87
+ * @param left - one host path.
88
+ * @param right - the other host path.
89
+ * @returns whether they name the same directory.
90
+ */
91
+ export declare function sameWorkspacePath(left: string, right: string): boolean;
92
+ /**
93
+ * The remote roots one session added, in file order.
94
+ *
95
+ * @param entries - the persisted root entries.
96
+ * @param workspacePath - the session's workspace path (its cwd), or `undefined` when the platform gave none.
97
+ * @param identity - the session identity, when the platform could supply one (ADR-0013).
98
+ * @returns the added remote roots, each with its connection and absolute remote path.
99
+ */
100
+ export declare function remoteRootsAddedBy(entries: readonly SessionRootEntry[], workspacePath: string | undefined, identity?: SessionIdentity): readonly SessionRootTarget[];
101
+ /**
102
+ * Pick the remote root a session should target when its cwd names none.
103
+ *
104
+ * @param entries - the persisted root entries.
105
+ * @param workspacePath - the session's workspace path (its cwd).
106
+ * @param identity - the session identity, when the platform could supply one (ADR-0013).
107
+ * @returns the single added remote root, a refusal, or `none` when the session added no remote directory.
108
+ */
109
+ export declare function resolveSessionRemoteRoot(entries: readonly SessionRootEntry[], workspacePath: string | undefined, identity?: SessionIdentity): SessionRootOutcome;
110
+ /**
111
+ * The model-facing text for a session that added more than one remote directory.
112
+ *
113
+ * @param candidates - the added remote roots.
114
+ * @returns one sentence naming every candidate and the way to disambiguate.
115
+ */
116
+ export declare function ambiguousSessionRootMessage(candidates: readonly SessionRootTarget[]): string;
117
+ /**
118
+ * Whether one root may serve one session — the **access** rule, not the view rule.
119
+ *
120
+ * 「添加目录」 scopes a root to the workspace that added it (`workspaces`). Until now that was a visibility fact
121
+ * only: the panel listed the session's own roots while the router answered **any** registered root for **any**
122
+ * caller (`routed-fs.ts`: "Filtering is a view fact, not a sandbox"). With the routed filesystem mounted
123
+ * (`远程工作区-路由fs`, stage 2) that gap is an access gap — session B could read a directory only session A
124
+ * added, merely by spelling its path. This predicate closes it, and it is the one place the rule lives: the route
125
+ * table calls it for every resolution, so every file tool inherits it.
126
+ *
127
+ * **Two identities, in this order (ADR-0013).** When the root carries `sessions` **and** the caller has a
128
+ * session id, the session decides — the workspace path is not consulted. That is what separates two
129
+ * conversations opened on the same directory: they share a cwd and differ only here. Otherwise (an entry from
130
+ * before ADR-0013, or an agentless call) the workspace path stands in, and the root belongs to a session in
131
+ * exactly two ways:
132
+ *
133
+ * 1. it was **added to this workspace** — `workspaces` names the session's cwd ({@link sameWorkspacePath});
134
+ * 2. the session **lives on** the root — a remote root's anchor directory *is* a workspace path, so a session
135
+ * opened on that anchor (or inside it) is that workspace's session. This keeps a remote workspace usable
136
+ * when its root was registered before the `workspaces` grouping existed.
137
+ *
138
+ * Everything else is refused, **including a root with no `workspaces` at all**: the settings panel's unscoped
139
+ * roots are "added to no session" (its own copy says so), so a session has to opt in with 「添加目录」 first.
140
+ * That is a deliberate behaviour change — see ADR-0012 for the migration note.
141
+ *
142
+ * A caller with neither cwd nor session is not a session call — agentless config replay, a CLI — and keeps the
143
+ * plugin-wide view; widening a *session* silently would be the bug this function exists to stop.
144
+ *
145
+ * @param root - one registered root.
146
+ * @param cwd - the asking session's cwd, or `undefined` for an agentless call.
147
+ * @param identity - the asking session's identity, when the platform could supply one (ADR-0013).
148
+ * @returns whether the root may serve that caller.
149
+ */
150
+ export declare function rootVisibleToSession(root: RootRef, cwd: string | undefined, identity?: SessionIdentity): boolean;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `~/.ssh/config` reader used by the "import aliases" flow.
3
+ *
4
+ * Only the keys this plugin can represent are read. OpenSSH keeps the first obtained value
5
+ * for a key inside one Host block, so this parser does the same; `Host` blocks accumulate
6
+ * their patterns and later blocks shadow earlier ones for the same literal alias.
7
+ *
8
+ * @module dsh-over-ssh/domain/ssh-config
9
+ */
10
+ /** One parsed `Host` block. */
11
+ export interface SshConfigEntry {
12
+ /** Patterns on the `Host` line, in order. */
13
+ readonly patterns: readonly string[];
14
+ /** `HostName` value when present. */
15
+ readonly hostName?: string;
16
+ /** `User` value when present. */
17
+ readonly user?: string;
18
+ /** `Port` value when present and numeric. */
19
+ readonly port?: number;
20
+ /** All `IdentityFile` values, in order. */
21
+ readonly identityFiles: readonly string[];
22
+ /** `ProxyJump` value when present and not `none`. */
23
+ readonly proxyJump?: string;
24
+ /** `Include` patterns (this parser does not read included files). */
25
+ readonly includes: readonly string[];
26
+ /** 1-based line number of the `Host` line. */
27
+ readonly line: number;
28
+ }
29
+ /** A connection the import flow can turn into a profile. */
30
+ export interface SshConfigImportDraft {
31
+ /** The literal alias, also usable as the `ssh` target. */
32
+ readonly alias: string;
33
+ /** Resolved host name (falls back to the alias). */
34
+ readonly hostName: string;
35
+ readonly user?: string;
36
+ readonly port?: number;
37
+ readonly identityFile?: string;
38
+ readonly proxyJump?: string;
39
+ }
40
+ /** One alias the import flow refuses, with the reason shown to the user. */
41
+ export interface SkippedAlias {
42
+ readonly pattern: string;
43
+ readonly reason: string;
44
+ readonly line: number;
45
+ }
46
+ /** Result of selecting importable aliases. */
47
+ export interface SshConfigImportResult {
48
+ readonly drafts: readonly SshConfigImportDraft[];
49
+ readonly skipped: readonly SkippedAlias[];
50
+ }
51
+ /**
52
+ * Parse the textual content of an OpenSSH client config.
53
+ *
54
+ * @param text - file content.
55
+ * @returns parsed Host blocks in file order.
56
+ */
57
+ export declare function parseSshConfig(text: string): SshConfigEntry[];
58
+ /** Whether a Host pattern names one concrete host rather than a wildcard set. */
59
+ export declare function isConcretePattern(pattern: string): boolean;
60
+ /** Expand a leading `~` using the host home directory. */
61
+ export declare function expandHome(path: string, home: string): string;
62
+ /**
63
+ * Select the aliases the import flow will offer, and explain what it refuses.
64
+ *
65
+ * @param entries - parsed Host blocks.
66
+ * @param options - `home` expands `~` in identity paths; `excluded` drops known aliases.
67
+ * @returns importable drafts plus skipped patterns.
68
+ */
69
+ export declare function importableEntries(entries: readonly SshConfigEntry[], options?: {
70
+ readonly home?: string;
71
+ readonly excluded?: readonly string[];
72
+ }): SshConfigImportResult;