summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ssh/src/service.ts')
-rw-r--r--packages/ssh/src/service.ts340
1 files changed, 170 insertions, 170 deletions
diff --git a/packages/ssh/src/service.ts b/packages/ssh/src/service.ts
index cebe2ef..c47a81e 100644
--- a/packages/ssh/src/service.ts
+++ b/packages/ssh/src/service.ts
@@ -30,132 +30,132 @@ import { createSshConnectionPool, type SshConnectionPool, type SshPoolDeps } fro
* same real edges against a real sshd.
*/
export interface SshServiceDeps extends SshPoolDeps {
- readonly logger: Logger;
- /** Read `~/.ssh/config` text (the source of truth — decision #4). */
- readonly readConfigText: () => Promise<string>;
- /** The current OS user (fallback when the config sets no `User`). */
- readonly defaultUser: string;
- /** Home dir, for resolving `~` in `IdentityFile`/default key probing. */
- readonly homeDir: string;
- /**
- * Optional: alias → usage count (conversations/workspaces referencing it).
- * host-bin wires this from conversation-store; absent → every count is 0.
- */
- readonly getUsageCounts?: () => Promise<ReadonlyMap<string, number>>;
- /**
- * Optional: glob patterns to exclude from the computer catalog (e.g.
- * `github.com`, `*.ts.net`). Sourced from `dispatch.toml` `[ssh].reject`.
- * Absent → no filtering.
- */
- readonly readRejectPatterns?: () => Promise<readonly string[]>;
+ readonly logger: Logger;
+ /** Read `~/.ssh/config` text (the source of truth — decision #4). */
+ readonly readConfigText: () => Promise<string>;
+ /** The current OS user (fallback when the config sets no `User`). */
+ readonly defaultUser: string;
+ /** Home dir, for resolving `~` in `IdentityFile`/default key probing. */
+ readonly homeDir: string;
+ /**
+ * Optional: alias → usage count (conversations/workspaces referencing it).
+ * host-bin wires this from conversation-store; absent → every count is 0.
+ */
+ readonly getUsageCounts?: () => Promise<ReadonlyMap<string, number>>;
+ /**
+ * Optional: glob patterns to exclude from the computer catalog (e.g.
+ * `github.com`, `*.ts.net`). Sourced from `dispatch.toml` `[ssh].reject`.
+ * Absent → no filtering.
+ */
+ readonly readRejectPatterns?: () => Promise<readonly string[]>;
}
/** Build the `ComputerService` + the remote-`ExecBackend` factory. */
export function createSshService(deps: SshServiceDeps): {
- readonly service: ComputerService;
- readonly pool: SshConnectionPool;
- /** `(computerId) => ExecBackend` — provided via remoteExecBackendFactoryHandle. */
- readonly remoteFactory: (computerId: string) => ExecBackend;
+ readonly service: ComputerService;
+ readonly pool: SshConnectionPool;
+ /** `(computerId) => ExecBackend` — provided via remoteExecBackendFactoryHandle. */
+ readonly remoteFactory: (computerId: string) => ExecBackend;
} {
- const pool = createSshConnectionPool(deps);
-
- async function readEnv(): Promise<SshConfigResolveEnv> {
- const [configText, knownHostsText, rejectPatterns] = await Promise.all([
- deps.readConfigText().catch(async () => ""),
- deps.readFileText(deps.knownHostsPath).catch(async () => ""),
- deps.readRejectPatterns !== undefined
- ? deps.readRejectPatterns().catch(async () => [] as readonly string[])
- : Promise.resolve([] as readonly string[]),
- ]);
- const base: SshConfigResolveEnv = {
- configText,
- knownHostsText,
- defaultUser: deps.defaultUser,
- homeDir: deps.homeDir,
- };
- return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base;
- }
-
- const service: ComputerService = {
- async listComputers(): Promise<readonly ComputerEntry[]> {
- const env = await readEnv();
- const computers = resolveComputers(env);
- const counts = deps.getUsageCounts !== undefined ? await deps.getUsageCounts() : new Map();
- return computers.map(
- (c): ComputerEntry => ({
- ...c,
- usageCount: counts.get(c.alias) ?? 0,
- }),
- );
- },
-
- async getComputer(alias: string): Promise<Computer | null> {
- const env = await readEnv();
- return resolveComputer(alias, env);
- },
-
- async getStatus(alias: string): Promise<ComputerStatusResponse> {
- const env = await readEnv();
- const computer = resolveComputer(alias, env);
- if (computer === null) {
- return {
- alias,
- state: "disconnected",
- knownHost: false,
- };
- }
- // Surface the pool's live state for this alias (disconnected if never
- // acquired; connecting/connected/error once a connect is attempted).
- const entry = pool.status().find((s) => s.computerId === alias);
- if (entry === undefined) {
- return { alias, state: "disconnected", knownHost: computer.knownHost };
- }
- if (entry.error !== undefined) {
- return { alias, state: "error", error: entry.error, knownHost: computer.knownHost };
- }
- return { alias, state: entry.state, knownHost: computer.knownHost };
- },
-
- async test(alias: string): Promise<TestComputerResponse> {
- const env = await readEnv();
- const computer = resolveComputer(alias, env);
- if (computer === null) {
- return { alias, ok: false, error: `unknown computer alias "${alias}"` };
- }
- // One-shot probe: acquire (connects), run a trivial command, then drop
- // the connection so a test never holds a pooled socket open (plan §9.1).
- // Wrapped in a timeout safety net so the endpoint ALWAYS responds —
- // even if the SSH connect/exec/drop hangs (the probe is non-interactive).
- const PROBE_TOTAL_TIMEOUT_MS = 30_000;
- try {
- const result = await Promise.race<TestComputerResponse>([
- runTestProbe(pool, alias, deps.logger),
- timeoutAfter<TestComputerResponse>(
- PROBE_TOTAL_TIMEOUT_MS,
- `test timed out after ${PROBE_TOTAL_TIMEOUT_MS / 1000}s`,
- ),
- ]);
- return result;
- } catch (err: unknown) {
- await pool.drop(alias).catch(() => undefined);
- const message = err instanceof Error ? err.message : String(err);
- deps.logger.warn("computer test failed", { alias, error: message });
- return { alias, ok: false, error: message };
- }
- },
- };
-
- /**
- * The factory exec-backend consumes: given a computerId (alias), return a
- * remote `ExecBackend`. The backend acquires lazily — merely building it
- * (in the resolver) opens NO connection; the first method call connects.
- * Only the alias is captured; the pool re-resolves connection params from
- * `~/.ssh/config` at connect time, so no stale snapshot is held here.
- */
- const remoteFactory = (computerId: string): ExecBackend =>
- createSshExecBackend(computerId, async (alias) => pool.acquire(alias));
-
- return { service, pool, remoteFactory };
+ const pool = createSshConnectionPool(deps);
+
+ async function readEnv(): Promise<SshConfigResolveEnv> {
+ const [configText, knownHostsText, rejectPatterns] = await Promise.all([
+ deps.readConfigText().catch(async () => ""),
+ deps.readFileText(deps.knownHostsPath).catch(async () => ""),
+ deps.readRejectPatterns !== undefined
+ ? deps.readRejectPatterns().catch(async () => [] as readonly string[])
+ : Promise.resolve([] as readonly string[]),
+ ]);
+ const base: SshConfigResolveEnv = {
+ configText,
+ knownHostsText,
+ defaultUser: deps.defaultUser,
+ homeDir: deps.homeDir,
+ };
+ return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base;
+ }
+
+ const service: ComputerService = {
+ async listComputers(): Promise<readonly ComputerEntry[]> {
+ const env = await readEnv();
+ const computers = resolveComputers(env);
+ const counts = deps.getUsageCounts !== undefined ? await deps.getUsageCounts() : new Map();
+ return computers.map(
+ (c): ComputerEntry => ({
+ ...c,
+ usageCount: counts.get(c.alias) ?? 0,
+ }),
+ );
+ },
+
+ async getComputer(alias: string): Promise<Computer | null> {
+ const env = await readEnv();
+ return resolveComputer(alias, env);
+ },
+
+ async getStatus(alias: string): Promise<ComputerStatusResponse> {
+ const env = await readEnv();
+ const computer = resolveComputer(alias, env);
+ if (computer === null) {
+ return {
+ alias,
+ state: "disconnected",
+ knownHost: false,
+ };
+ }
+ // Surface the pool's live state for this alias (disconnected if never
+ // acquired; connecting/connected/error once a connect is attempted).
+ const entry = pool.status().find((s) => s.computerId === alias);
+ if (entry === undefined) {
+ return { alias, state: "disconnected", knownHost: computer.knownHost };
+ }
+ if (entry.error !== undefined) {
+ return { alias, state: "error", error: entry.error, knownHost: computer.knownHost };
+ }
+ return { alias, state: entry.state, knownHost: computer.knownHost };
+ },
+
+ async test(alias: string): Promise<TestComputerResponse> {
+ const env = await readEnv();
+ const computer = resolveComputer(alias, env);
+ if (computer === null) {
+ return { alias, ok: false, error: `unknown computer alias "${alias}"` };
+ }
+ // One-shot probe: acquire (connects), run a trivial command, then drop
+ // the connection so a test never holds a pooled socket open (plan §9.1).
+ // Wrapped in a timeout safety net so the endpoint ALWAYS responds —
+ // even if the SSH connect/exec/drop hangs (the probe is non-interactive).
+ const PROBE_TOTAL_TIMEOUT_MS = 30_000;
+ try {
+ const result = await Promise.race<TestComputerResponse>([
+ runTestProbe(pool, alias, deps.logger),
+ timeoutAfter<TestComputerResponse>(
+ PROBE_TOTAL_TIMEOUT_MS,
+ `test timed out after ${PROBE_TOTAL_TIMEOUT_MS / 1000}s`,
+ ),
+ ]);
+ return result;
+ } catch (err: unknown) {
+ await pool.drop(alias).catch(() => undefined);
+ const message = err instanceof Error ? err.message : String(err);
+ deps.logger.warn("computer test failed", { alias, error: message });
+ return { alias, ok: false, error: message };
+ }
+ },
+ };
+
+ /**
+ * The factory exec-backend consumes: given a computerId (alias), return a
+ * remote `ExecBackend`. The backend acquires lazily — merely building it
+ * (in the resolver) opens NO connection; the first method call connects.
+ * Only the alias is captured; the pool re-resolves connection params from
+ * `~/.ssh/config` at connect time, so no stale snapshot is held here.
+ */
+ const remoteFactory = (computerId: string): ExecBackend =>
+ createSshExecBackend(computerId, async (alias) => pool.acquire(alias));
+
+ return { service, pool, remoteFactory };
}
/**
@@ -163,27 +163,27 @@ export function createSshService(deps: SshServiceDeps): {
* be raced against a timeout. Always drops the connection (even on success).
*/
async function runTestProbe(
- pool: SshConnectionPool,
- alias: string,
- logger: Logger,
+ pool: SshConnectionPool,
+ alias: string,
+ logger: Logger,
): Promise<TestComputerResponse> {
- const conn = await pool.acquire(alias);
- const client = await conn.getClient();
- const ok = await runProbe(client);
- if (ok) {
- logger.info("computer test ok", { alias });
- }
- await pool.drop(alias);
- return ok
- ? { alias, ok: true }
- : { alias, ok: false, error: "remote command returned no exit code" };
+ const conn = await pool.acquire(alias);
+ const client = await conn.getClient();
+ const ok = await runProbe(client);
+ if (ok) {
+ logger.info("computer test ok", { alias });
+ }
+ await pool.drop(alias);
+ return ok
+ ? { alias, ok: true }
+ : { alias, ok: false, error: "remote command returned no exit code" };
}
/** Reject with `message` after `ms`. Used to race against a hanging probe. */
function timeoutAfter<T>(ms: number, message: string): Promise<T> {
- return new Promise<T>((_resolve, reject) => {
- setTimeout(() => reject(new Error(message)), ms);
- });
+ return new Promise<T>((_resolve, reject) => {
+ setTimeout(() => reject(new Error(message)), ms);
+ });
}
/** Probe timeout — the `true` command exits instantly; 15s is generous. */
@@ -198,37 +198,37 @@ const PROBE_TIMEOUT_MS = 15_000;
* the `exit` event never fires (e.g. the server requires a pty for exec).
*/
function runProbe(client: import("ssh2").Client): Promise<boolean> {
- return new Promise<boolean>((resolve) => {
- let settled = false;
- const done = (result: boolean): void => {
- if (!settled) {
- settled = true;
- clearTimeout(timer);
- resolve(result);
- }
- };
-
- const timer = setTimeout(() => {
- done(false);
- }, PROBE_TIMEOUT_MS);
-
- client.exec("true", { pty: false }, (err, stream) => {
- if (err !== null && err !== undefined) {
- done(false);
- return;
- }
- // Resolve on `exit` — the command has finished. Don't wait for
- // `close` (some servers never emit it for exec channels).
- stream.on("exit", (code: number | null) => {
- done(code === 0);
- });
- // Safety net: if `exit` never fires, `close` might.
- stream.on("close", () => {
- done(false);
- });
- // Drain any output so the stream doesn't deadlock.
- stream.on("data", () => {});
- stream.stderr.on("data", () => {});
- });
- });
+ return new Promise<boolean>((resolve) => {
+ let settled = false;
+ const done = (result: boolean): void => {
+ if (!settled) {
+ settled = true;
+ clearTimeout(timer);
+ resolve(result);
+ }
+ };
+
+ const timer = setTimeout(() => {
+ done(false);
+ }, PROBE_TIMEOUT_MS);
+
+ client.exec("true", { pty: false }, (err, stream) => {
+ if (err !== null && err !== undefined) {
+ done(false);
+ return;
+ }
+ // Resolve on `exit` — the command has finished. Don't wait for
+ // `close` (some servers never emit it for exec channels).
+ stream.on("exit", (code: number | null) => {
+ done(code === 0);
+ });
+ // Safety net: if `exit` never fires, `close` might.
+ stream.on("close", () => {
+ done(false);
+ });
+ // Drain any output so the stream doesn't deadlock.
+ stream.on("data", () => {});
+ stream.stderr.on("data", () => {});
+ });
+ });
}