feat(pi-ssh): add reviewed agent connection flow

This commit is contained in:
云服务部-叶林立
2026-08-21 20:52:50 +08:00
parent 0ac50eb581
commit a7891f18bf
20 changed files with 208 additions and 203 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.9.0 - 2026-08-21
- Add reviewed, agent-callable `ssh_connect` for explicitly imported hosts.
- Remove the user-side `/ssh` command, `--ssh` startup flag, session-resume reconnect, and remote user-`!` override.
- Route connection authorization through the existing `pi-permission-system` and AutoReview chain with a non-secret target preview.
- Disconnect active transports at session shutdown and require an explicit reviewed connection in each session.
## 0.8.0 - 2026-08-20
- Replace the OpenSSH subprocess, ControlMaster, and persistent PTY implementation with a pure `ssh2` transport.
+12 -14
View File
@@ -2,6 +2,7 @@
`pi-ssh` keeps Pi and its local tools on the local machine while exposing explicit remote tools over a persistent Node `ssh2` connection:
- `ssh_connect`
- `ssh_read`
- `ssh_write`
- `ssh_edit`
@@ -17,6 +18,8 @@ Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and do
Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <alias>` once to resolve the selected alias, then stores the resulting endpoint and authentication data in the pi-ssh vault. Later changes to `~/.ssh/config` require re-importing the host.
Connections are agent-controlled: when the user explicitly names an imported host as part of a concrete remote task, the model calls `ssh_connect`. The connection request enters the normal permission and auto-review chain before any network connection is opened. The extension does not register `/ssh`, `--ssh`, automatic session resume, or remote user-`!` overrides.
ProxyJump and ProxyCommand are intentionally rejected in the first ssh2 release.
## Configure hosts
@@ -73,29 +76,23 @@ Private-key contents are not copied into the vault.
## Usage
Connect interactively:
Ask Pi for a concrete task on an imported host, for example:
```text
/ssh
/ssh packaging-server
/ssh packaging-server:/absolute/remote/path
/ssh status
/ssh off
Connect to packaging-server, inspect the API logs, and identify the recent 500 errors.
```
Or at startup:
The model first calls:
```sh
pi --ssh packaging-server
pi --ssh packaging-server:/absolute/remote/path
```text
ssh_connect({ hostId: "packaging-server" })
```
Only imported host IDs are accepted. Arbitrary `user@host` targets are rejected.
The active host ID and remote cwd are stored in the Pi session for resume. Credentials are never stored in Pi session entries.
It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. After the reviewed connection succeeds, the model uses the other `ssh_*` tools to complete the requested work. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. A new connection replaces the previous active connection, and session shutdown disconnects it.
## Runtime behavior
- `ssh_connect` is the only runtime connection surface; it is agent-callable and permission-reviewed.
- One persistent `ssh2` client is used for the active host.
- Each `ssh_bash` call opens an exec channel and runs under `bash -lc` in the selected remote cwd.
- SFTP provides remote reads and writes.
@@ -128,6 +125,7 @@ The remote host must provide `bash`. SFTP support is required for file tools.
All remote operations enter the bundle's existing permission chain:
- `ssh_connect` starts as `ask`, so AutoReview can verify that the direct user request names the requested imported host;
- `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`;
- `ssh_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`;
- deterministic hard denies remain denies;
@@ -143,7 +141,7 @@ Permission evidence includes the configured host ID, resolved endpoint, port, re
- Treat both vault files as secrets even though `hosts.enc` is encrypted.
- The extension's threat model does not protect credentials from malicious code already running as the same local user.
- Password keyboard-interactive mode reuses the configured password for the server's prompts; use it only with a trusted pinned host.
- Remote content reaches the model only through an explicit `ssh_*` call or explicit user `!` command.
- Remote content reaches the model only through an explicit reviewed `ssh_*` tool call.
## Development
+10 -7
View File
@@ -4,6 +4,7 @@
Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server:
- `ssh_connect`
- `ssh_read`
- `ssh_write`
- `ssh_edit`
@@ -26,13 +27,14 @@ ssh_config.sh import <alias>
→ encrypted vault is updated
```
The resulting host ID is the only runtime selector:
The resulting host ID is the only runtime selector. When a direct user request names that imported host as part of a concrete remote task, the agent calls:
```text
/ssh <host-id>[:/absolute/path]
pi --ssh <host-id>[:/absolute/path]
ssh_connect({ hostId: "<host-id>", remotePath?: "/absolute/or/~/path" })
```
`ssh_connect` is the only runtime connection surface. It is a model tool governed by `pi-permission-system`; there is no `/ssh` command, `--ssh` flag, session-resume reconnect, or user `!` remote-shell override.
Arbitrary `user@host`, port overrides, ProxyJump, and ProxyCommand are not supported in the first pure-ssh2 version. Unsupported imported configuration is rejected rather than ignored.
## Vault
@@ -71,7 +73,7 @@ One persistent `ssh2.Client` belongs to the active host. Connection loss fails c
### Shell
`ssh_bash` and explicit user `!` commands open exec channels. Commands run through `bash -lc` after changing to the selected remote cwd. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
`ssh_bash` opens exec channels. Commands run through `bash -lc` after changing to the selected remote cwd. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
### Files
@@ -103,6 +105,7 @@ Direct search commands remain denied through `ssh_bash`; structured search tools
There is one permission gate: `pi-permission-system`.
- `ssh_connect` defaults to `ask`; its preview resolves the imported host ID to the non-secret endpoint, port, and requested/default cwd before connection.
- `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`.
- `ssh_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`.
- Bash hard denies remain denies.
@@ -111,8 +114,8 @@ There is one permission gate: `pi-permission-system`.
Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials.
## Session and UI
## Session lifecycle
The session stores only host ID, remote cwd, and remote home. Resume reloads current vault data and establishes a new ssh2 connection; failures fall back to local mode. `/ssh off` disposes the client and clears the status line.
Connections are created only by an approved `ssh_connect` call after session start. They are not persisted or automatically resumed. Connecting another imported host disposes the previous client, and session shutdown disposes the active client.
The system prompt states that default tools are local and `ssh_*` tools are remote. It does not include remote file content.
The system prompt states that default tools are local and `ssh_*` tools are remote after a connection becomes active. It does not include remote file content.
+43 -164
View File
@@ -1,6 +1,6 @@
import { homedir } from "node:os";
import { posix as posixPath } from "node:path";
import type { CustomEntry, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
createBashTool,
createEditTool,
@@ -12,7 +12,15 @@ import {
type WriteOperations,
} from "@earendil-works/pi-coding-agent";
import { getPermissionsService, PERMISSIONS_READY_CHANNEL } from "@gotgenes/pi-permission-system";
import { installSshPermissionIntegration } from "./permission-integration.ts";
import {
installSshPermissionIntegration,
type SshPermissionConnection,
} from "./permission-integration.ts";
import {
parseConnectInput,
SSH_CONNECT_TOOL_METADATA,
type HostSelection,
} from "./src/agent-connection.ts";
import { loadVault } from "./src/vault.ts";
import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts";
import type { PiSshConfig, SshHostConfig } from "./src/config.ts";
@@ -23,11 +31,6 @@ import {
type RemoteGrepInput,
} from "./src/remote-search.ts";
interface SshStoredConfig {
hostId: string;
remoteCwd: string;
remoteHome: string;
}
interface SshConnection {
hostId: string;
@@ -39,24 +42,6 @@ interface SshConnection {
localHome: string;
}
interface HostSelection {
hostId: string;
remotePath?: string;
}
function parseHostSelection(raw: string): HostSelection {
const value = raw.trim();
if (!value) throw new Error("SSH host id is required");
const colon = value.indexOf(":");
const hostId = (colon < 0 ? value : value.slice(0, colon)).trim();
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)) throw new Error(`invalid pi-ssh host id: ${hostId}`);
if (colon < 0) return { hostId };
const remotePath = value.slice(colon + 1).trim();
if (!remotePath || !(remotePath === "~" || remotePath.startsWith("~/") || remotePath.startsWith("/"))) {
throw new Error("remote path must be absolute or start with ~/");
}
return { hostId, remotePath };
}
function mapLocalPathToRemote(path: string, connection: SshConnection): string {
if (path === connection.localCwd) return connection.remoteCwd;
@@ -163,22 +148,21 @@ async function connectSelection(
}
}
function pickerEntries(config: PiSshConfig): Array<{ value: string; hostId: string }> {
const groupByHost = new Map<string, string>();
for (const group of Object.values(config.groups ?? {})) {
for (const hostId of group.hosts) if (!groupByHost.has(hostId)) groupByHost.set(hostId, group.label);
function describeRequestedTarget(input: Record<string, unknown>): SshPermissionConnection | null {
try {
const selection = parseConnectInput(input);
const host = getConfiguredHost(loadVault(), selection.hostId);
return {
remote: `${selection.hostId} [${host.user}@${host.hostName}]`,
port: host.port,
remoteCwd: selection.remotePath ?? host.defaultCwd ?? "<server default>",
};
} catch {
return null;
}
return Object.entries(config.hosts).map(([hostId, host]) => ({
hostId,
value: `${groupByHost.get(hostId) ? `${groupByHost.get(hostId)} / ` : ""}${host.label ?? hostId} [${hostId}]`,
}));
}
export default function piSshExtension(pi: ExtensionAPI): void {
pi.registerFlag("ssh", {
description: "Configured pi-ssh host id, optionally followed by :/absolute/remote/path",
type: "string",
});
const localCwd = process.cwd();
const localHome = homedir();
@@ -193,11 +177,12 @@ export default function piSshExtension(pi: ExtensionAPI): void {
installSshPermissionIntegration(pi, getConnection, {
getPermissionsService,
permissionsReadyChannel: PERMISSIONS_READY_CHANNEL,
getConnectTarget: describeRequestedTarget,
});
const requireSsh = (toolName: string): { connection: SshConnection; transport: Ssh2Transport } => {
if (!connection || !transport) {
throw new Error(`${toolName} requires an active SSH2 connection. Run /ssh and select an imported host.`);
throw new Error(`${toolName} requires an active SSH2 connection. Call ssh_connect with an imported host ID first.`);
}
return { connection, transport };
};
@@ -205,33 +190,31 @@ export default function piSshExtension(pi: ExtensionAPI): void {
const activateConnection = async (
nextConnection: SshConnection,
nextTransport: Ssh2Transport,
ctx: ExtensionContext,
options: { persist: boolean; verb: string },
): Promise<void> => {
if (transport) await transport.dispose();
connection = nextConnection;
transport = nextTransport;
if (options.persist) {
pi.appendEntry("pi-ssh-config", {
hostId: nextConnection.hostId,
remoteCwd: nextConnection.remoteCwd,
remoteHome: nextConnection.remoteHome,
} satisfies SshStoredConfig);
}
const message = `pi-ssh ${options.verb}: ${nextConnection.remote}:${nextConnection.remoteCwd} (port ${nextConnection.port})`;
console.log(message);
if (ctx.hasUI) {
ctx.ui.setStatus("pi-ssh", ctx.ui.theme.fg("accent", `SSH ${nextConnection.hostId}:${nextConnection.remoteCwd}`));
ctx.ui.notify(message, "info");
}
console.log(`pi-ssh connected: ${nextConnection.remote}:${nextConnection.remoteCwd} (port ${nextConnection.port})`);
};
const deactivateConnection = async (ctx: ExtensionContext): Promise<void> => {
if (transport) await transport.dispose();
transport = null;
connection = null;
if (ctx.hasUI) ctx.ui.setStatus("pi-ssh", undefined);
};
pi.registerTool({
...SSH_CONNECT_TOOL_METADATA,
async execute(_id, params) {
const selection = parseConnectInput(params as Record<string, unknown>);
const connected = await connectSelection(selection, localCwd, localHome);
await activateConnection(connected.connection, connected.transport);
const text = `Connected to ${connected.connection.remote}:${connected.connection.remoteCwd} (port ${connected.connection.port}).`;
return {
content: [{ type: "text", text }],
details: {
hostId: connected.connection.hostId,
remote: connected.connection.remote,
port: connected.connection.port,
remoteCwd: connected.connection.remoteCwd,
},
};
},
});
pi.registerTool({
...localRead,
@@ -346,115 +329,12 @@ export default function piSshExtension(pi: ExtensionAPI): void {
},
});
pi.on("session_start", async (event, ctx) => {
const flag = pi.getFlag("ssh") as string | undefined;
if (flag) {
try {
const connected = await connectSelection(parseHostSelection(flag), localCwd, localHome);
await activateConnection(connected.connection, connected.transport, ctx, { persist: true, verb: "enabled" });
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await deactivateConnection(ctx);
console.error(`pi-ssh failed to connect: ${message}`);
if (ctx.hasUI) ctx.ui.notify(`pi-ssh failed to connect: ${message}`, "error");
throw error;
}
}
if (event.reason !== "startup" && event.reason !== "resume") return;
const entries = ctx.sessionManager.getEntries();
let stored: SshStoredConfig | undefined;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.type === "custom" && (entry as CustomEntry<unknown>).customType === "pi-ssh-config") {
stored = (entry as CustomEntry<SshStoredConfig>).data;
if (stored) break;
}
}
if (!stored) return;
try {
const connected = await connectSelection(
{ hostId: stored.hostId, remotePath: stored.remoteCwd },
localCwd,
localHome,
);
await activateConnection(connected.connection, connected.transport, ctx, { persist: false, verb: "resumed" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await deactivateConnection(ctx);
console.error(`pi-ssh resume failed: ${message}`);
if (ctx.hasUI) ctx.ui.notify(`pi-ssh resume failed: ${message}`, "warning");
}
});
pi.registerCommand("ssh", {
description: "Connect configured SSH2 hosts: /ssh [host-id[:/path]], /ssh status, /ssh off",
getArgumentCompletions: (prefix) => {
try {
const options = ["off", "status", ...Object.keys(loadVault().hosts)];
const filtered = options.filter((option) => option.startsWith(prefix));
return filtered.length > 0 ? filtered.map((option) => ({ value: option, label: option })) : null;
} catch {
return ["off", "status"].filter((option) => option.startsWith(prefix)).map((option) => ({ value: option, label: option }));
}
},
handler: async (args, ctx) => {
const input = args.trim();
if (input === "status") {
ctx.ui.notify(connection
? `pi-ssh: ${connection.remote}:${connection.remoteCwd} (port ${connection.port})`
: "pi-ssh: not connected (local tools active)", "info");
return;
}
if (input === "off") {
await deactivateConnection(ctx);
ctx.ui.notify("pi-ssh: disconnected", "info");
return;
}
let target = input;
if (!target) {
let config: PiSshConfig;
try {
config = loadVault();
} catch (error) {
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
return;
}
const entries = pickerEntries(config);
if (entries.length === 0) {
ctx.ui.notify("No pi-ssh hosts configured. Run ssh_config.sh import.", "warning");
return;
}
const values = [...(connection ? ["Disconnect [off]"] : []), ...entries.map((entry) => entry.value)];
const selected = await ctx.ui.select("SSH2 host", values);
if (!selected) return;
if (selected === "Disconnect [off]") {
await deactivateConnection(ctx);
ctx.ui.notify("pi-ssh: disconnected", "info");
return;
}
target = entries.find((entry) => entry.value === selected)?.hostId ?? "";
}
try {
const connected = await connectSelection(parseHostSelection(target), localCwd, localHome);
await activateConnection(connected.connection, connected.transport, ctx, { persist: true, verb: "connected" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`pi-ssh: failed to connect: ${message}`, "error");
}
},
});
pi.on("session_shutdown", async () => {
if (transport) await transport.dispose();
transport = null;
connection = null;
});
pi.on("user_bash", () => transport ? { operations: createRemoteBashOps(transport) } : undefined);
pi.on("before_agent_start", async (event) => {
if (!connection) return;
const guidance =
@@ -463,7 +343,6 @@ export default function piSshExtension(pi: ExtensionAPI): void {
`The default read/write/edit/bash/find/grep tools act on the LOCAL machine. ` +
`Use ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
`Use ssh_find before ssh_grep to narrow remote searches; both tools return bounded results and choose the fastest available remote backend. ` +
`(User \`!\` commands run remotely.) ` +
`Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory.`;
return { systemPrompt: `${event.systemPrompt}${guidance}` };
});
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "pi-ssh",
"version": "0.8.0",
"description": "Explicit remote SSH tools for Pi using a pure ssh2 transport and encrypted host vault",
"version": "0.9.0",
"description": "Reviewed agent-controlled SSH tools for Pi using a pure ssh2 transport and encrypted host vault",
"type": "module",
"private": false,
"main": "index.ts",
+15 -4
View File
@@ -10,6 +10,7 @@ export interface SshPermissionConnection {
export interface SshPermissionIntegrationDependencies {
getPermissionsService: () => PermissionsService | undefined;
permissionsReadyChannel: string;
getConnectTarget?: (input: Record<string, unknown>) => SshPermissionConnection | null;
warn?: (message: string) => void;
}
@@ -17,7 +18,7 @@ type PermissionIntegrationApi = Pick<ExtensionAPI, "events" | "on">;
type ToolInput = Record<string, unknown>;
const REMOTE_FILE_TOOLS = ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
const REMOTE_TOOLS = [...REMOTE_FILE_TOOLS, "ssh_bash"] as const;
const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_FILE_TOOLS, "ssh_bash"] as const;
function inline(value: string, limit = 240): string {
const normalized = value.replace(/\s+/g, " ").trim();
@@ -47,6 +48,13 @@ export function formatSshPermissionInput(
const target = formatTarget(connection);
const path = stringField(input, "path");
if (toolName === "ssh_connect") {
if (connection !== null) return `${target}; establish a persistent SSH2 connection`;
const hostId = inline(stringField(input, "hostId") ?? "<unspecified>");
const remotePath = stringField(input, "remotePath");
return `requested imported SSH host '${hostId}'${remotePath ? ` in remote cwd '${inline(remotePath)}'` : ""}; establish a persistent SSH2 connection`;
}
if (toolName === "ssh_read") {
const details = path ? [`remote path '${inline(path)}'`] : ["an unspecified remote path"];
if (typeof input.offset === "number") details.push(`offset ${input.offset}`);
@@ -124,9 +132,12 @@ export function installSshPermissionIntegration(
try {
for (const toolName of REMOTE_TOOLS) {
pending.push(
service.registerToolInputFormatter(toolName, (input) =>
formatSshPermissionInput(toolName, input, getConnection()),
),
service.registerToolInputFormatter(toolName, (input) => {
const target = toolName === "ssh_connect"
? dependencies.getConnectTarget?.(input) ?? null
: getConnection();
return formatSshPermissionInput(toolName, input, target);
}),
);
}
for (const toolName of REMOTE_FILE_TOOLS) {
+33
View File
@@ -0,0 +1,33 @@
export interface HostSelection {
hostId: string;
remotePath?: string;
}
export const SSH_CONNECT_TOOL_METADATA = {
name: "ssh_connect",
label: "ssh_connect",
description: "Establish a persistent SSH2 connection to an explicitly imported host. Use this when the user names a remote server as part of a concrete task; the connection request is reviewed before any network connection is opened.",
parameters: {
type: "object",
properties: {
hostId: { type: "string", description: "Imported pi-ssh host ID explicitly named by the user" },
remotePath: { type: "string", description: "Optional remote cwd; must be absolute, ~, or start with ~/" },
},
required: ["hostId"],
additionalProperties: false,
},
} as const;
export function parseConnectInput(input: Record<string, unknown>): HostSelection {
const hostId = typeof input.hostId === "string" ? input.hostId.trim() : "";
if (!hostId) throw new Error("SSH host id is required");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)) throw new Error(`invalid pi-ssh host id: ${hostId}`);
if (input.remotePath === undefined) return { hostId };
if (typeof input.remotePath !== "string") throw new Error("remote path must be a string");
const remotePath = input.remotePath.trim();
if (!remotePath || !(remotePath === "~" || remotePath.startsWith("~/") || remotePath.startsWith("/"))) {
throw new Error("remote path must be absolute or start with ~/");
}
return { hostId, remotePath };
}
+28
View File
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
test("defines the reviewed agent-controlled SSH connection tool", () => {
assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect");
assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]);
assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath);
assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" });
assert.deepEqual(parseConnectInput({ hostId: "packaging-server", remotePath: "~/api" }), {
hostId: "packaging-server",
remotePath: "~/api",
});
assert.throws(() => parseConnectInput({ hostId: "user@host" }), /invalid pi-ssh host id/);
assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/);
});
test("removes manual and implicit SSH connection surfaces", async () => {
const source = await readFile(new URL("../index.ts", import.meta.url), "utf8");
assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/);
assert.doesNotMatch(source, /registerCommand\(["']ssh["']/);
assert.doesNotMatch(source, /registerFlag\(["']ssh["']/);
assert.doesNotMatch(source, /getFlag\(["']ssh["']/);
assert.doesNotMatch(source, /appendEntry\(["']pi-ssh-config["']/);
assert.doesNotMatch(source, /pi\.on\(["']user_bash["']/);
});
+19 -3
View File
@@ -45,6 +45,17 @@ const connection: SshPermissionConnection = {
remoteCwd: "/srv/build",
};
test("formats reviewed connection requests without exposing credentials", () => {
assert.equal(
formatSshPermissionInput("ssh_connect", { hostId: "packaging-server", remotePath: "/srv/build" }, connection),
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; establish a persistent SSH2 connection",
);
assert.equal(
formatSshPermissionInput("ssh_connect", { hostId: "unknown" }, null),
"requested imported SSH host 'unknown'; establish a persistent SSH2 connection",
);
});
test("formats the SSH target and bounded operation details", () => {
assert.equal(
formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection),
@@ -66,14 +77,19 @@ test("registers previews and disables local path extraction for remote file tool
const dispose = installSshPermissionIntegration(
pi.api as never,
() => connection,
{ getPermissionsService: () => service, permissionsReadyChannel: "permissions:ready" },
{
getPermissionsService: () => service,
permissionsReadyChannel: "permissions:ready",
getConnectTarget: () => connection,
},
);
assert.deepEqual([...formatters.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/);
dispose();
assert.equal(formatters.size, 0);
@@ -92,7 +108,7 @@ test("registers when the permission service becomes ready and cleans up on shutd
published = service;
pi.emitEvent("permissions:ready");
assert.equal(formatters.size, 6);
assert.equal(formatters.size, 7);
assert.equal(extractors.size, 5);
pi.emit("session_shutdown");