mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat(pi-ssh): add reviewed agent connection flow
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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["']/);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user