dsh-over-ssh 0.2.3 → 0.2.4

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,132 @@
1
+ /**
2
+ * Non-interactive **password** authentication for the system `ssh` client, via `SSH_ASKPASS`.
3
+ *
4
+ * Why this module exists: the Tier 2 agent is launched by spawning `ssh` (`ssh <alias> <agent> --stdio`),
5
+ * and the alias transport runs every command the same way. System `ssh` cannot read a password from a pipe,
6
+ * so a password-only connection used to be impossible on any transport that goes through `ssh` — which made
7
+ * "default to Tier 2" silently false for password connections (they were downgraded to `ssh2`). That was
8
+ * never a property of SSH: OpenSSH ≥ 8.4 supports `SSH_ASKPASS_REQUIRE=force`, which runs a helper program
9
+ * to obtain the secret **even with no tty**, exactly the situation a background plugin is in.
10
+ *
11
+ * Two rules make this safe:
12
+ *
13
+ * 1. **The secret never touches the disk.** The helper program carries no secret; it forwards the value of
14
+ * an environment variable (`DSH_OVER_SSH_ASKPASS_SECRET`) that the ssh child process inherits. A leftover
15
+ * helper file therefore leaks nothing: with the variable absent it prints an empty line and ssh fails
16
+ * the authentication instead of succeeding with a stale credential.
17
+ * 2. **The secret is never interpolated into a shell or command line.** It travels only as an environment
18
+ * value, so characters like `&`, `|`, `%`, `^` and quotes need no escaping (a `cmd`-based helper that
19
+ * echoed the secret would break on those, and its argv would be visible in the process list).
20
+ *
21
+ * Verified on the real host (Windows, OpenSSH_for_Windows_9.5p1): password auth succeeds through a spawned
22
+ * `ssh` whose stdio are pipes, and a **wrong** secret fails — so the helper is genuinely what authenticates.
23
+ *
24
+ * @module dsh-over-ssh/infra/transport/askpass
25
+ */
26
+ /** The interpreter that runs the helper script. */
27
+ export interface AskpassInterpreter {
28
+ /** Absolute path of the executable (`node`/Electron). */
29
+ readonly execPath: string;
30
+ /** `'win32'` or a POSIX platform id; decides which shim shape is written. */
31
+ readonly platform: string;
32
+ }
33
+ /** The two files a helper is made of, and the exact text written to each. */
34
+ export interface AskpassFiles {
35
+ /** The program `SSH_ASKPASS` points at. */
36
+ readonly helperPath: string;
37
+ /** The script the helper delegates to. */
38
+ readonly scriptPath: string;
39
+ /** Contents of {@link scriptPath}; carries no secret. */
40
+ readonly script: string;
41
+ /** Contents of {@link helperPath}; carries no secret. */
42
+ readonly helper: string;
43
+ /** Mode to create the files with (POSIX needs the helper executable). */
44
+ readonly mode: number;
45
+ }
46
+ /**
47
+ * The helper script: print the secret from the environment, then a newline.
48
+ *
49
+ * A trailing newline is required — an ssh askpass program whose output lacks one is rejected
50
+ * (measured: `<nul set /p =` without a newline failed while `echo` succeeded).
51
+ */
52
+ export declare function askpassScript(): string;
53
+ /**
54
+ * The program `SSH_ASKPASS` names.
55
+ *
56
+ * Windows: a `cmd` shim, because ssh executes the path directly and a `.mjs` is not executable there.
57
+ * POSIX: a `sh` script. Both only *launch* the interpreter with the script path, so the secret is never
58
+ * quoted into a command line.
59
+ *
60
+ * @param interpreter - interpreter and platform.
61
+ * @param scriptPath - absolute path of {@link askpassScript}'s file.
62
+ * @returns the shim text.
63
+ */
64
+ export declare function askpassHelper(interpreter: AskpassInterpreter, scriptPath: string): string;
65
+ /**
66
+ * Build the files for one helper directory.
67
+ *
68
+ * @param dir - directory to place the helper in.
69
+ * @param interpreter - interpreter and platform.
70
+ * @returns the file plan (paths plus contents).
71
+ */
72
+ export declare function planAskpassFiles(dir: string, interpreter: AskpassInterpreter): AskpassFiles;
73
+ /**
74
+ * The environment that makes the system `ssh` ask {@link AskpassFiles.helperPath} for the password.
75
+ *
76
+ * `ELECTRON_RUN_AS_NODE=1` is set unconditionally: this plugin normally runs inside the Electron desktop
77
+ * app, where `process.execPath` is the Electron binary and would otherwise open a GUI instead of running
78
+ * the script. Real `node` ignores the variable, so one code path covers both hosts.
79
+ *
80
+ * `DISPLAY` is set because pre-8.4 OpenSSH only consults `SSH_ASKPASS` when it is non-empty, and
81
+ * `SSH_ASKPASS_REQUIRE=force` because ≥ 8.4 additionally requires it when a tty might exist. Setting both
82
+ * makes the helper work on either generation.
83
+ *
84
+ * @param files - the helper plan.
85
+ * @param secret - the password; only ever passed here, never written to a file.
86
+ * @returns environment entries to merge into the `ssh` child's environment.
87
+ */
88
+ export declare function askpassEnvironment(files: AskpassFiles, secret: string): Record<string, string>;
89
+ /** Filesystem surface this module needs; injected so tests never touch a real disk. */
90
+ export interface AskpassIo {
91
+ /** Create `dir` (and parents). */
92
+ mkdir(dir: string): Promise<void>;
93
+ /** Write `content` to `path`, optionally with a POSIX mode. */
94
+ writeFile(path: string, content: string, mode: number): Promise<void>;
95
+ /** Remove `dir` recursively; never throws when it is already gone. */
96
+ remove(dir: string): Promise<void>;
97
+ /** Create a private directory to hold one helper. */
98
+ makeTempDir(): Promise<string>;
99
+ }
100
+ /** A live helper: the environment to hand `ssh`, plus a way to take it away again. */
101
+ export interface AskpassHandle {
102
+ /** Environment entries for the `ssh` child process. */
103
+ readonly env: Record<string, string>;
104
+ /** Delete the helper directory. Safe to call more than once. */
105
+ dispose(): Promise<void>;
106
+ }
107
+ /** Options for {@link createAskpass}. */
108
+ export interface AskpassOptions {
109
+ /** The password. Held in memory only. */
110
+ readonly secret: string;
111
+ /** Interpreter facts; defaults to this process. */
112
+ readonly interpreter?: AskpassInterpreter;
113
+ /** Injected filesystem. */
114
+ readonly io: AskpassIo;
115
+ }
116
+ /**
117
+ * Create a password helper and the environment that uses it.
118
+ *
119
+ * @param options - secret, interpreter and injected filesystem.
120
+ * @returns the handle; call {@link AskpassHandle.dispose} when the connection goes away.
121
+ */
122
+ export declare function createAskpass(options: AskpassOptions): Promise<AskpassHandle>;
123
+ /**
124
+ * Whether a connection's password can be handed to the system `ssh` at all.
125
+ *
126
+ * Only `authKind: 'password'` needs the helper: key and agent connections never prompt, and forcing
127
+ * `BatchMode=no` for them would let a misconfigured host block on an interactive prompt forever.
128
+ *
129
+ * @param authKind - the connection's authentication mode.
130
+ * @returns true when the askpass helper must be wired in.
131
+ */
132
+ export declare function needsAskpass(authKind: 'agent' | 'key' | 'password'): boolean;
@@ -67,6 +67,10 @@ export interface ExecResult {
67
67
  readonly timedOut: boolean;
68
68
  readonly stdout: string;
69
69
  readonly stderr: string;
70
+ /** Exact bytes of stdout when it was **not truncated** and is not valid UTF-8; `stdout` is then lossy. */
71
+ readonly stdoutBase64?: string | undefined;
72
+ /** Exact bytes of stderr when it was **not truncated** and is not valid UTF-8; `stderr` is then lossy. */
73
+ readonly stderrBase64?: string | undefined;
70
74
  readonly durationMs: number;
71
75
  /**
72
76
  * Whether **stdout** was cut at `maxBytes`.
@@ -452,6 +456,39 @@ export interface Transport {
452
456
  * `readFile`; a test pins that identity because it is the contract's entire purpose.
453
457
  */
454
458
  statPath?(path: string, signal?: AbortSignal): Promise<RemoteStatResult>;
459
+ /**
460
+ * Read several file ranges in **one** round trip (agent `fs.readMany`).
461
+ *
462
+ * Optional and its absence is meaningful: Tier 1 shell transports have no batch primitive, so the caller
463
+ * falls back to parallel single reads. Present, a model asking for five files pays one RTT instead of five.
464
+ */
465
+ batchRead?(requests: readonly {
466
+ readonly path: string;
467
+ readonly offset?: number;
468
+ readonly maxBytes?: number;
469
+ }[], signal?: AbortSignal): Promise<readonly BatchReadChunk[]>;
470
+ /**
471
+ * Probe one TCP endpoint **from the remote host** (agent `net.probe`).
472
+ *
473
+ * Optional: absent when the agent predates the method. The forward tool uses it to fail a dead port at
474
+ * `start` time instead of relying on the `ssh -W` child's 150 ms probe window.
475
+ */
476
+ netProbe?(host: string, port: number, signal?: AbortSignal): Promise<{
477
+ readonly reachable: boolean;
478
+ readonly error?: string;
479
+ }>;
480
+ /** One-round-trip project overview (agent `repo.summary`). Optional; absent means the agent predates it. */
481
+ repoSummary?(root: string, signal?: AbortSignal): Promise<Record<string, unknown>>;
482
+ /** Multi-file edit in one round trip (agent `fs.patchMany`). Optional, same rule. */
483
+ patchMany?(edits: readonly {
484
+ readonly path: string;
485
+ readonly expectedVersion?: string;
486
+ readonly find?: string;
487
+ readonly replace?: string;
488
+ readonly all?: boolean;
489
+ readonly create?: boolean;
490
+ readonly patch?: string;
491
+ }[], atomic: boolean, signal?: AbortSignal): Promise<Record<string, unknown>>;
455
492
  /**
456
493
  * Atomic in-place edit performed by the agent itself (`fs.edit`).
457
494
  *
@@ -565,3 +602,14 @@ export declare function encodeBase64(bytes: Uint8Array): string;
565
602
  * 那次拷贝是几十字节,与热路径无关。
566
603
  */
567
604
  export declare function decodeBase64(text: string): Uint8Array;
605
+ /** One chunk returned by {@link Transport.batchRead}. */
606
+ export interface BatchReadChunk {
607
+ readonly path: string;
608
+ readonly exists: boolean;
609
+ readonly size: number;
610
+ readonly version: string;
611
+ readonly truncated: boolean;
612
+ readonly bytes: Uint8Array;
613
+ /** Per-item failure (e.g. permission); the batch itself still succeeded. */
614
+ readonly error?: string;
615
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Per-connection store of `SSH_ASKPASS` helpers.
3
+ *
4
+ * Why this exists as a module: preparing a helper is **async** (it reads the secret from the credential
5
+ * service and writes two files), but transport factories are **synchronous** and are also reached from
6
+ * `createAlias` during the install ladder. The two are joined here: something async calls
7
+ * {@link AskpassRegistry.prepare} first, and the factory then reads {@link AskpassRegistry.envFor}
8
+ * without awaiting.
9
+ *
10
+ * Three rules:
11
+ *
12
+ * 1. **One helper per connection.** Preparing twice returns the same handle, so a reconnect does not
13
+ * accumulate temp directories (and the secret is read once).
14
+ * 2. **Only password connections get one.** Key and agent connections never prompt; handing them
15
+ * `BatchMode=no` would let a misconfigured host block on an interactive prompt forever.
16
+ * 3. **A failure is a degradation, not a throw.** If the secret cannot be resolved the connection still
17
+ * works through the transports that do not need it; {@link AskpassRegistry.prepare} reports `undefined`
18
+ * and the caller keeps its previous behaviour.
19
+ *
20
+ * @module dsh-over-ssh/integration/askpass-registry
21
+ */
22
+ import { type AskpassHandle } from '../../infra/transport/ssh/askpass.ts';
23
+ import type { ConnectionProfile } from '../../domain/roots/connection.ts';
24
+ /** Options for {@link createAskpassRegistry}. */
25
+ export interface AskpassRegistryOptions {
26
+ /** Resolve the connection's password. Throws when it is missing or the credential service is absent. */
27
+ readonly readSecret: (profile: ConnectionProfile) => Promise<string>;
28
+ /** Materialise one helper for a secret. */
29
+ readonly create: (secret: string) => Promise<AskpassHandle>;
30
+ /** Optional sink for "we could not prepare one" so the reason is not swallowed. */
31
+ readonly onUnavailable?: (connectionId: string, reason: string) => void;
32
+ }
33
+ /** The registry surface the composition root needs. */
34
+ export interface AskpassRegistry {
35
+ /**
36
+ * Ensure a helper exists for this connection.
37
+ *
38
+ * @param profile - the connection.
39
+ * @returns the environment to hand `ssh`, or `undefined` when this connection needs none or preparation
40
+ * failed (in which case the caller must keep its non-prompting behaviour).
41
+ */
42
+ prepare(profile: ConnectionProfile): Promise<Record<string, string> | undefined>;
43
+ /** The environment prepared for this connection, if any. Synchronous, for transport factories. */
44
+ envFor(profile: ConnectionProfile): Record<string, string> | undefined;
45
+ /** Whether a connection is currently using a password helper. */
46
+ has(connectionId: string): boolean;
47
+ /** Drop one connection's helper (its transport was released, or the connection changed). */
48
+ forget(connectionId: string): Promise<void>;
49
+ /**
50
+ * Allow a retired connection id to prepare a helper again.
51
+ *
52
+ * Call this only when the connection is deliberately re-established. Without it, a re-saved connection
53
+ * could never get a password helper, because `forget` leaves a tombstone that `prepare` honours.
54
+ */
55
+ revive(connectionId: string): void;
56
+ /** Drop every helper (plugin unload). */
57
+ disposeAll(): Promise<void>;
58
+ }
59
+ /**
60
+ * Build the registry.
61
+ *
62
+ * @param options - injected secret reader, helper factory and optional sink.
63
+ * @returns the registry.
64
+ */
65
+ export declare function createAskpassRegistry(options: AskpassRegistryOptions): AskpassRegistry;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The remote file index behind `@` completion.
3
+ *
4
+ * Requirement FR-2.5 asks for completion over a remote root: prefer `git ls-files` (fast, respects
5
+ * `.gitignore`), fall back to a **bounded** `find` on hosts without git (our benchmark host has no git),
6
+ * cache the result, and keep a 900 ms budget so typing never stalls.
7
+ *
8
+ * Two tiers serve it, and the choice is reported rather than hidden:
9
+ *
10
+ * - **Tier 2** (`agentScan`) runs the agent's `index.scan`, which measured ~0.16 ms/file on a real host;
11
+ * - **Tier 1** runs one shell command over the transport (measured 187–248 ms for 2000 files).
12
+ *
13
+ * Budgets are enforced by passing them down (`timeoutMs`), not by hoping: an over-budget result comes back
14
+ * with `timedOut: true` and whatever was enumerated, so the UI can show partial completion instead of
15
+ * freezing. Nothing here silently returns a short list as if it were complete.
16
+ *
17
+ * @module dsh-over-ssh/integration/remote-index
18
+ */
19
+ import { type RemoteCapabilities } from '../../domain/capability.ts';
20
+ /** Where a result came from. */
21
+ export type IndexSource = 'agent-index' | 'git-ls-files' | 'find-bounded' | 'cache';
22
+ /** What the index needs from the two tiers. All injected, so this module stays offline-testable. */
23
+ export interface RemoteIndexTiers {
24
+ /** Tier 2: the agent's `index.scan`. Absent when the connection has no agent. */
25
+ readonly agentScan?: (root: string, options: {
26
+ readonly maxEntries: number;
27
+ readonly timeoutMs: number;
28
+ readonly signal?: AbortSignal;
29
+ }) => Promise<{
30
+ readonly paths: readonly string[];
31
+ readonly strategy: string;
32
+ readonly truncated: boolean;
33
+ }>;
34
+ /**
35
+ * Tier 2: the agent's `index.refresh` (incremental).
36
+ *
37
+ * Only useful **after** a scan the agent still holds a snapshot for; the agent falls back to a full scan
38
+ * on its own when it cannot refresh, so a caller may always prefer this once it has scanned once. A
39
+ * transport that predates the method reports `REMOTE_UNSUPPORTED`, which this module treats as "use
40
+ * agentScan" — never as a failed completion.
41
+ */
42
+ readonly agentRefresh?: (root: string, options: {
43
+ readonly maxEntries: number;
44
+ readonly timeoutMs: number;
45
+ readonly signal?: AbortSignal;
46
+ }) => Promise<{
47
+ readonly paths: readonly string[];
48
+ readonly strategy: string;
49
+ readonly truncated: boolean;
50
+ }>;
51
+ /** Tier 1: one shell command over the transport. */
52
+ readonly exec: (command: string, options: {
53
+ readonly timeoutMs: number;
54
+ readonly maxBytes: number;
55
+ readonly signal?: AbortSignal;
56
+ }) => Promise<{
57
+ readonly exitCode: number;
58
+ readonly stdout: string;
59
+ readonly stderr: string;
60
+ readonly truncated?: boolean;
61
+ }>;
62
+ /** Tier 1 capability facts, used to pick the enumeration command. */
63
+ readonly capabilities: () => Promise<RemoteCapabilities>;
64
+ }
65
+ /** Options for {@link RemoteIndex}. */
66
+ export interface RemoteIndexOptions {
67
+ readonly tiers: RemoteIndexTiers;
68
+ /** How long an enumerated root stays usable. Default 30 s. */
69
+ readonly ttlMs?: number;
70
+ /** How many roots to keep. Default 8. */
71
+ readonly maxRoots?: number;
72
+ /**
73
+ * Cap for the "already scanned" hint set (default {@link DEFAULTS.maxScannedRoots}).
74
+ *
75
+ * Exposed so a test can drive the bound with a small number instead of allocating 257 roots.
76
+ */
77
+ readonly maxScannedRoots?: number;
78
+ /** Default enumeration budget. Default 900 ms (FR-2.5). */
79
+ readonly budgetMs?: number;
80
+ /** Default entry cap. Default 20 000. */
81
+ readonly maxEntries?: number;
82
+ /** Byte cap for the cached paths; defaults to {@link DEFAULTS.maxBytes}. */
83
+ readonly maxBytes?: number;
84
+ /** Injectable clock, for the TTL and the budget assertions. */
85
+ readonly now?: () => number;
86
+ }
87
+ /** One enumeration result. */
88
+ export interface IndexResult {
89
+ readonly paths: readonly string[];
90
+ readonly source: IndexSource;
91
+ /** The agent's own strategy string, when Tier 2 served it (`find-bounded`, `git`, …). */
92
+ readonly agentStrategy?: string;
93
+ readonly truncated: boolean;
94
+ /** True when the enumeration stopped because the budget ran out. */
95
+ readonly timedOut: boolean;
96
+ /**
97
+ * Whether the result was cached.
98
+ *
99
+ * `false` only for an enumeration that never completed (killed at the deadline, connection lost): such a
100
+ * result must not be remembered, while a slow-but-successful one must be — the whole point of the cache
101
+ * is to spare the *next* keystroke, and throwing away a good list because it took 1.2 s would mean
102
+ * re-running it for every keystroke (found by the real-host measurement).
103
+ */
104
+ readonly cacheable: boolean;
105
+ readonly ms: number;
106
+ readonly cached: boolean;
107
+ }
108
+ /** One completion request. */
109
+ export interface CompletionRequest {
110
+ readonly root: string;
111
+ /** What the user has typed after `@`. */
112
+ readonly query: string;
113
+ readonly limit?: number;
114
+ readonly budgetMs?: number;
115
+ readonly signal?: AbortSignal;
116
+ }
117
+ /** One completion result. */
118
+ export interface CompletionResult {
119
+ readonly matches: readonly string[];
120
+ readonly source: IndexSource;
121
+ readonly truncated: boolean;
122
+ readonly timedOut: boolean;
123
+ readonly ms: number;
124
+ readonly cached: boolean;
125
+ }
126
+ /** Enumerates a remote root, with TTL caching, ranking and budget accounting. */
127
+ export declare class RemoteIndex {
128
+ #private;
129
+ constructor(options: RemoteIndexOptions);
130
+ /** Enumerate one root (or serve it from cache while fresh). */
131
+ enumerate(root: string, options?: {
132
+ readonly maxEntries?: number;
133
+ readonly budgetMs?: number;
134
+ readonly force?: boolean;
135
+ readonly signal?: AbortSignal;
136
+ }): Promise<IndexResult>;
137
+ /**
138
+ * Completion for a partially typed `@` query.
139
+ *
140
+ * Ranking is deliberate and cheap: a basename prefix match beats a path prefix match, which beats a
141
+ * substring match — so typing `@main` prefers `main.ts` over `src/domain/main-helper.ts`.
142
+ */
143
+ complete(request: CompletionRequest): Promise<CompletionResult>;
144
+ /**
145
+ * Drop one root (its files changed) or everything.
146
+ *
147
+ * **Byte accounting must follow.** Dropping entries without subtracting their bytes made `#cacheBytes` grow
148
+ * monotonically: after a few invalidate+remember cycles the cap saw a phantom overrun and evicted every live
149
+ * entry except the newest one, which turned each `@` completion back into a full scan — the exact cost this
150
+ * cache exists to remove.
151
+ */
152
+ invalidate(root?: string): void;
153
+ /** Whether a root is currently cached and fresh (used by tests and by the status UI). */
154
+ isFresh(root: string): boolean;
155
+ }
156
+ /**
157
+ * `git ls-files` for one root.
158
+ *
159
+ * `--cached --others --exclude-standard` lists tracked and untracked-but-not-ignored files, which is what
160
+ * completion should offer; `head -n` bounds the output before it crosses the wire.
161
+ */
162
+ export declare function gitCommand(root: string, maxEntries: number): string;
163
+ /** Bounded `find` for hosts without git (the benchmark host): files only, no `.git` internals. */
164
+ export declare function findCommand(root: string, maxEntries: number): string;
165
+ /**
166
+ * The cheap "is this root a git work tree" probe.
167
+ *
168
+ * `git ls-files` in the enumeration command is piped into `head`, so its failure (exit 128 in a
169
+ * non-repository directory) never reaches the caller. This command has no pipe, so its status is git's own:
170
+ * exit 0 plus `true` on stdout for a work tree, exit non-zero (or `false`) for anything else.
171
+ */
172
+ export declare function gitRepoProbeCommand(root: string): string;
173
+ /** The cheap "does this directory exist" probe for the find strategy (see `#confirmEmpty`). */
174
+ export declare function directoryProbeCommand(root: string): string;
175
+ /**
176
+ * Rank paths for a partially typed query.
177
+ *
178
+ * @param paths - enumerated paths (absolute or root-relative; both work).
179
+ * @param query - what the user typed; empty means "show the shallowest entries first".
180
+ * @param limit - maximum matches.
181
+ * @returns up to `limit` paths, best first.
182
+ */
183
+ export declare function rankMatches(paths: readonly string[], query: string, limit: number): readonly string[];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Resolve a connection's **home directory** on the remote host.
3
+ *
4
+ * Why this exists: the directory browser used to start remote listings at `/` (the filesystem root), which is
5
+ * almost never what someone adding a remote folder wants — and on a shared server it shows every other
6
+ * account's home. The obvious "fix" of spelling `home/<user>` is **wrong**, and this host proves it: the login
7
+ * user's home is `/home1/yezhiyuan` while `/home/yezhiyuan` does not exist, because the site spreads accounts
8
+ * across `/home`, `/home1`, `/home2`, … (an NFS/automount convention). Only the host can answer the question,
9
+ * so the answer is **probed, never guessed**.
10
+ *
11
+ * Precedence, highest first:
12
+ *
13
+ * 1. `remoteDefaultRoot` from the connection's own configuration — an explicit choice always wins.
14
+ * 2. A probe of the login user's home (`printf %s "$HOME"`), run over the connection's transport.
15
+ * 3. `/`, the old behaviour, when the probe cannot answer (a transport without command execution, a shell
16
+ * that prints nothing, a path that is not absolute). Degrading to the previous behaviour keeps a browse
17
+ * surface usable instead of empty.
18
+ *
19
+ * Results are cached per connection: the answer is a property of the account, not of the moment, and browsing
20
+ * re-asks on every level change. {@link RemoteHomeResolver.forget} drops one entry when a connection changes.
21
+ *
22
+ * @module dsh-over-ssh/integration/remote-home
23
+ */
24
+ /** The fallback every path of last resort uses. */
25
+ export declare const REMOTE_ROOT_FALLBACK = "/";
26
+ /** Options for {@link createRemoteHomeResolver}. */
27
+ export interface RemoteHomeResolverOptions {
28
+ /**
29
+ * Probe the login user's home over the connection's transport.
30
+ *
31
+ * @returns the absolute path, or `undefined` when the host could not answer.
32
+ */
33
+ readonly probe: (connectionId: string) => Promise<string | undefined>;
34
+ /** The connection's configured `remoteDefaultRoot`, when it has one. */
35
+ readonly configuredRoot?: (connectionId: string) => string | undefined;
36
+ }
37
+ /** The resolver the route calls. */
38
+ export interface RemoteHomeResolver {
39
+ /**
40
+ * The directory a remote browse should start at.
41
+ *
42
+ * @param connectionId - the connection to resolve for.
43
+ * @returns an absolute path; never empty, and never throws (failures degrade to `/`).
44
+ */
45
+ resolve(connectionId: string): Promise<string>;
46
+ /** Drop one connection's cached answer (its profile changed, or it was removed). */
47
+ forget(connectionId: string): void;
48
+ /** Drop every cached answer. */
49
+ disposeAll(): void;
50
+ }
51
+ /**
52
+ * Whether a string is usable as a remote directory to list.
53
+ *
54
+ * The same fence the route applies to client-supplied paths: absolute, and no `..` segment. A probe that
55
+ * answers something else (a shell banner, a relative path, a trailing newline artefact) must not become the
56
+ * listing target.
57
+ *
58
+ * @param value - candidate path.
59
+ * @returns the normalised path, or `undefined` when it is unusable.
60
+ */
61
+ export declare function normalizeRemoteDir(value: string | undefined): string | undefined;
62
+ /**
63
+ * Build the resolver.
64
+ *
65
+ * @param options - injected probe and configuration reader.
66
+ * @returns the resolver.
67
+ */
68
+ export declare function createRemoteHomeResolver(options: RemoteHomeResolverOptions): RemoteHomeResolver;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The root registry: which directories a workspace has, and which backend serves each one.
3
+ *
4
+ * It owns exactly one {@link RoutedFileSystem} and mutates it in place, so the object handed to the
5
+ * core `ctx.fs` provider keeps its identity across root changes (a rebuilt router would silently
6
+ * invalidate every target key a caller is holding).
7
+ *
8
+ * Backend construction is injected: the local and remote backends need `node:fs` and a transport
9
+ * respectively, and keeping those out of this module is what makes the registry testable without a
10
+ * filesystem or a network.
11
+ *
12
+ * The registry also owns the **session workspace roots** ({@link RootRegistry.syncSessionWorkspaces}): one
13
+ * hidden, session-scoped local root per live session cwd, so a session's own working directory is always
14
+ * resolvable — including by absolute path without a cwd, which is how the platform reads the workspace while a
15
+ * turn starts.
16
+ *
17
+ * @module dsh-over-ssh/integration/root-registry
18
+ */
19
+ import type { Backend } from '../../app/backend.ts';
20
+ import { RoutedFileSystem, type RoutedTarget } from '../../app/routed-fs.ts';
21
+ import { type RootOptions, type RootRef } from '../../domain/roots/root.ts';
22
+ /** One registered root together with its backend. */
23
+ export interface RootRecord {
24
+ readonly key: string;
25
+ readonly root: RootRef;
26
+ readonly backend: Backend;
27
+ }
28
+ /** Options for {@link RootRegistry}. */
29
+ export interface RootRegistryOptions {
30
+ /** Builds the backend for a local root. */
31
+ readonly createLocalBackend: (root: RootRef) => Backend;
32
+ /** Builds the backend for a remote root (one per connection in this design). */
33
+ readonly createRemoteBackend: (root: RootRef) => Backend;
34
+ readonly caseInsensitive?: boolean;
35
+ readonly maxReadBytes?: number;
36
+ /** Called after every mutation, so a client half or a status view can refresh. */
37
+ readonly onChanged?: (records: readonly RootRecord[]) => void;
38
+ }
39
+ /** Remote-root options. */
40
+ export interface RemoteRootOptions extends RootOptions {
41
+ /** Local empty anchor directory; DSH requires a workspace path that exists on the host. */
42
+ readonly anchorPath?: string;
43
+ }
44
+ /** The set of roots of one workspace. */
45
+ export declare class RootRegistry {
46
+ #private;
47
+ constructor(options: RootRegistryOptions);
48
+ /** The router every consumer reads through (`ctx.fs` wraps this). */
49
+ get routed(): RoutedFileSystem;
50
+ /** Registered roots in registration order. **Hidden roots are not here** (see {@link addHidden}). */
51
+ get records(): readonly RootRecord[];
52
+ /**
53
+ * Register a root that only the router knows about.
54
+ *
55
+ * Used for the file-level recycle bin, which has to live outside the caller's repository and therefore
56
+ * outside every visible root (ADR-0011). The backend is built exactly as for a visible root, so routing,
57
+ * `stat`, `list`, `move` and `remove` all behave normally — what changes is only that nothing lists it.
58
+ *
59
+ * Idempotent: registering the same key twice returns the existing record instead of throwing, because the
60
+ * caller (a bin resolver) may be asked for the same root repeatedly and a duplicate is not an error there.
61
+ *
62
+ * @param root - the hidden root to register.
63
+ * @returns the record, newly created or already present.
64
+ */
65
+ addHidden(root: RootRef): RootRecord;
66
+ /** Hidden roots registered so far (for tests and diagnostics — never for a user-facing list). */
67
+ get hiddenRecords(): readonly RootRecord[];
68
+ /** Look up one hidden root by key. */
69
+ getHidden(key: string): RootRecord | undefined;
70
+ /** Remove one hidden root by key (used when a connection's bin can no longer be resolved). */
71
+ removeHidden(key: string): boolean;
72
+ /** Session-workspace roots added by {@link syncSessionWorkspaces} — diagnostics and tests, never a list. */
73
+ get sessionWorkspaceRecords(): readonly RootRecord[];
74
+ /**
75
+ * Register the **live sessions' working directories** as session-scoped local roots.
76
+ *
77
+ * The failure this fixes is turn-level, not tool-level: DSH reads the workspace (`AGENTS.md` and friends)
78
+ * through `ctx.fs` while a turn is starting, and `@deepseek-ai/dsh-agent-instructions` resolves that file by
79
+ * **absolute path without a cwd** (`fileSystem.resolve(join(current, marker), { signal })`). A cwd no
80
+ * registered root covers therefore failed the whole turn before the model was called — and a fallback keyed on
81
+ * `cwd` cannot help, because that call carries none. So every live session's working directory becomes a real
82
+ * local root: **in memory only** (never written to `user-roots.json`), scoped to its session
83
+ * (`workspaces: [cwd]`, which {@link rootVisibleToSession} already reads), and hidden
84
+ * ({@link addHidden}) so no surface built from `records` shows a directory the user never added.
85
+ *
86
+ * Idempotent and cheap: a cwd already covered by a local root is skipped, so a registered root keeps its
87
+ * priority and its backend; a cwd that already has its session root is reused. Roots whose session is gone are
88
+ * pruned — an access grant must not outlive the session it was created for — but only when the caller reports
89
+ * sessions at all: an empty list means "the session service was not readable", not "no session is live", and
90
+ * dropping every grant on a transient read failure would break live turns.
91
+ *
92
+ * @param cwds - working directories of the live sessions, optionally with the session id that owns each one.
93
+ * With an id the root carries `sessions: [id]` and is writable **only by that session**; without one it is a
94
+ * workspace-scoped root (the legacy shape tests and agentless compositions use).
95
+ * @returns the session roots this call added (empty when every cwd was already covered).
96
+ */
97
+ syncSessionWorkspaces(cwds: readonly (string | {
98
+ readonly sessionId: string;
99
+ readonly cwd: string;
100
+ })[]): readonly RootRecord[];
101
+ /** Register a local directory root. */
102
+ addLocal(path: string, options?: RootOptions): RootRecord;
103
+ /** Register a remote directory root on one connection. */
104
+ addRemote(connectionId: string, path: string, options?: RemoteRootOptions): RootRecord;
105
+ /** Register an already-built root reference. */
106
+ add(root: RootRef): RootRecord;
107
+ /** Remove one root by key. */
108
+ remove(key: string): boolean;
109
+ /**
110
+ * Remove one root by a key that may differ in case from the registered one.
111
+ *
112
+ * Windows local keys are folded by {@link rootKey}, while the file and the client keep the caller's case, so
113
+ * a removal that only matched verbatim removed the file entry and left the root live. The exact match is
114
+ * tried first; the fallback folds **only local keys**, where case is not significant on Windows and where
115
+ * the mismatch can only come from that spelling difference.
116
+ *
117
+ * @param key - the key the caller holds.
118
+ * @returns the key the root was registered under, so related caches can forget the same spelling; `undefined`
119
+ * when nothing matched.
120
+ */
121
+ removeByLooseKey(key: string): string | undefined;
122
+ /** Replace every root (workspace switch, profile reload). Hidden roots go too: they are derived from these. */
123
+ reset(roots: readonly RootRef[]): void;
124
+ /** Roots belonging to one connection, for connection teardown and status. */
125
+ forConnection(connectionId: string): readonly RootRecord[];
126
+ /** Look one root up by key. */
127
+ get(key: string): RootRecord | undefined;
128
+ /**
129
+ * Remove every root of one connection (the connection was deleted or its transport is gone).
130
+ *
131
+ * @param connectionId - connection whose roots should disappear.
132
+ * @returns the removed records, so a caller can report what was dropped.
133
+ */
134
+ removeConnection(connectionId: string): readonly RootRecord[];
135
+ }
136
+ /** Declaration merging so the provider can read the registry from `ctx` with a typed property. */
137
+ declare module '@deepseek-ai/cordis' {
138
+ interface Context {
139
+ /** Root registry provided by this plugin. */
140
+ sshDevRoots: RootRegistry;
141
+ }
142
+ }
143
+ /** Narrow view used by the `ctx.fs` provider: it only needs the router. */
144
+ export interface RoutedTargetOwner {
145
+ readonly routed: RoutedFileSystem;
146
+ }
147
+ /** Re-exported for consumers that only need the target type. */
148
+ export type { RoutedTarget };