d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
230 lines
8.2 KiB
TypeScript
230 lines
8.2 KiB
TypeScript
/**
|
|
* SSH-backed file and shell tools for local-mode Pi runs. A single `ssh2`
|
|
* connection is opened per run and reused across every tool call: `read`/`write`/
|
|
* `edit` go over SFTP, `bash` over a shell exec scoped to the repo directory.
|
|
* All paths are sanitized and confined to the configured `repoPath` (S4).
|
|
*/
|
|
|
|
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import type { Client, SFTPWrapper } from 'ssh2'
|
|
import {
|
|
createSSHConnection,
|
|
escapeShellArg,
|
|
executeSSHCommand,
|
|
sanitizeCommand,
|
|
sanitizePath,
|
|
} from '@/app/api/tools/ssh/utils'
|
|
import type { PiSshConnection, PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
|
|
|
|
const logger = createLogger('PiSshTools')
|
|
|
|
/** An open SSH session reused for the duration of a local Pi run. */
|
|
export interface PiSshSession {
|
|
client: Client
|
|
sftp: SFTPWrapper
|
|
close: () => void
|
|
}
|
|
|
|
/** Opens one SSH connection plus an SFTP channel for the run. */
|
|
export async function openSshSession(connection: PiSshConnection): Promise<PiSshSession> {
|
|
const client = await createSSHConnection({
|
|
host: connection.host,
|
|
port: connection.port,
|
|
username: connection.username,
|
|
password: connection.password ?? null,
|
|
privateKey: connection.privateKey ?? null,
|
|
passphrase: connection.passphrase ?? null,
|
|
})
|
|
|
|
const close = () => {
|
|
try {
|
|
client.end()
|
|
} catch (error) {
|
|
logger.warn('Failed to close SSH session', { error: getErrorMessage(error) })
|
|
}
|
|
}
|
|
|
|
// The TCP/SSH connection is already open here, so close it if opening the SFTP
|
|
// channel fails (e.g. the server has the SFTP subsystem disabled) — otherwise
|
|
// the connection is orphaned when this function throws.
|
|
try {
|
|
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
|
|
client.sftp((err, channel) => (err ? reject(err) : resolve(channel)))
|
|
})
|
|
return { client, sftp, close }
|
|
} catch (error) {
|
|
close()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function readRemoteFile(sftp: SFTPWrapper, path: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
sftp.readFile(path, (err, data) => (err ? reject(err) : resolve(data.toString('utf-8'))))
|
|
})
|
|
}
|
|
|
|
function writeRemoteFile(sftp: SFTPWrapper, path: string, content: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
sftp.writeFile(path, content, (err) => (err ? reject(err) : resolve()))
|
|
})
|
|
}
|
|
|
|
/** Resolves a tool-supplied path against `repoPath`, rejecting traversal/escape. */
|
|
function resolveRepoPath(repoPath: string, candidate: string): string {
|
|
const clean = sanitizePath(candidate)
|
|
const root = repoPath.replace(/\/+$/, '')
|
|
if (clean.startsWith('/')) {
|
|
if (clean !== root && !clean.startsWith(`${root}/`)) {
|
|
throw new Error(`Path is outside the repository: ${candidate}`)
|
|
}
|
|
return clean
|
|
}
|
|
return `${root}/${clean}`
|
|
}
|
|
|
|
function asString(value: unknown): string {
|
|
return typeof value === 'string' ? value : ''
|
|
}
|
|
|
|
async function guard(run: () => Promise<PiToolResult>): Promise<PiToolResult> {
|
|
try {
|
|
return await run()
|
|
} catch (error) {
|
|
return { text: getErrorMessage(error, 'SSH tool failed'), isError: true }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Best-effort working-tree snapshot of the repo over the run's SSH session, for
|
|
* the block's `changedFiles`/`diff` outputs — Local mode edits in place rather
|
|
* than opening a PR. `changedFiles` covers both tracked modifications and untracked
|
|
* (newly created) files so files the agent created are reported; `diff` reflects
|
|
* tracked changes against HEAD. Returns empty on any failure (not a git repo, git
|
|
* missing, non-zero exit).
|
|
*/
|
|
export async function captureRepoChanges(
|
|
session: PiSshSession,
|
|
repoPath: string,
|
|
maxDiffBytes: number
|
|
): Promise<{ changedFiles: string[]; diff: string }> {
|
|
const scoped = `cd '${escapeShellArg(repoPath)}'`
|
|
try {
|
|
const tracked = await executeSSHCommand(
|
|
session.client,
|
|
`${scoped} && git diff --name-only HEAD`
|
|
)
|
|
const untracked = await executeSSHCommand(
|
|
session.client,
|
|
`${scoped} && git ls-files --others --exclude-standard`
|
|
)
|
|
const fileSet = new Set<string>()
|
|
for (const result of [tracked, untracked]) {
|
|
if (result.exitCode !== 0) continue
|
|
for (const line of result.stdout.split('\n')) {
|
|
const file = line.trim()
|
|
if (file) fileSet.add(file)
|
|
}
|
|
}
|
|
const raw = await executeSSHCommand(session.client, `${scoped} && git diff HEAD`)
|
|
const out = raw.exitCode === 0 ? raw.stdout : ''
|
|
const diff = out.length > maxDiffBytes ? `${out.slice(0, maxDiffBytes)}\n[diff truncated]` : out
|
|
return { changedFiles: [...fileSet], diff }
|
|
} catch {
|
|
return { changedFiles: [], diff: '' }
|
|
}
|
|
}
|
|
|
|
/** Builds the SSH-backed `read`/`write`/`edit`/`bash` tools scoped to `repoPath`. */
|
|
export function buildSshToolSpecs(session: PiSshSession, repoPath: string): PiToolSpec[] {
|
|
const { client, sftp } = session
|
|
|
|
return [
|
|
{
|
|
name: 'read',
|
|
description: 'Read the full contents of a file in the repository.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { path: { type: 'string', description: 'File path within the repository' } },
|
|
required: ['path'],
|
|
},
|
|
execute: (args) =>
|
|
guard(async () => {
|
|
const path = asString(args.path)
|
|
if (!path) return { text: 'path is required', isError: true }
|
|
const content = await readRemoteFile(sftp, resolveRepoPath(repoPath, path))
|
|
return { text: content, isError: false }
|
|
}),
|
|
},
|
|
{
|
|
name: 'write',
|
|
description: 'Write (create or overwrite) a file in the repository.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: { type: 'string', description: 'File path within the repository' },
|
|
content: { type: 'string', description: 'Full file contents to write' },
|
|
},
|
|
required: ['path', 'content'],
|
|
},
|
|
execute: (args) =>
|
|
guard(async () => {
|
|
const path = asString(args.path)
|
|
if (!path) return { text: 'path is required', isError: true }
|
|
const resolved = resolveRepoPath(repoPath, path)
|
|
await writeRemoteFile(sftp, resolved, asString(args.content))
|
|
return { text: `Wrote ${resolved}`, isError: false }
|
|
}),
|
|
},
|
|
{
|
|
name: 'edit',
|
|
description: 'Replace the first occurrence of old_string with new_string in a file.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: { type: 'string', description: 'File path within the repository' },
|
|
old_string: { type: 'string', description: 'Exact text to replace' },
|
|
new_string: { type: 'string', description: 'Replacement text' },
|
|
},
|
|
required: ['path', 'old_string', 'new_string'],
|
|
},
|
|
execute: (args) =>
|
|
guard(async () => {
|
|
const path = asString(args.path)
|
|
if (!path) return { text: 'path is required', isError: true }
|
|
const oldString = asString(args.old_string)
|
|
const resolved = resolveRepoPath(repoPath, path)
|
|
const current = await readRemoteFile(sftp, resolved)
|
|
if (!current.includes(oldString)) {
|
|
return { text: `old_string not found in ${resolved}`, isError: true }
|
|
}
|
|
const updated = current.replace(oldString, asString(args.new_string))
|
|
await writeRemoteFile(sftp, resolved, updated)
|
|
return { text: `Edited ${resolved}`, isError: false }
|
|
}),
|
|
},
|
|
{
|
|
name: 'bash',
|
|
description: 'Run a shell command in the repository directory and return its output.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { command: { type: 'string', description: 'Shell command to run' } },
|
|
required: ['command'],
|
|
},
|
|
execute: (args) =>
|
|
guard(async () => {
|
|
const command = asString(args.command)
|
|
if (!command) return { text: 'command is required', isError: true }
|
|
const scoped = `cd '${escapeShellArg(repoPath)}' && ${sanitizeCommand(command)}`
|
|
const result = await executeSSHCommand(client, scoped)
|
|
const text = [result.stdout, result.stderr].filter(Boolean).join('\n')
|
|
return {
|
|
text: text || `Exited with code ${result.exitCode}`,
|
|
isError: result.exitCode !== 0,
|
|
}
|
|
}),
|
|
},
|
|
]
|
|
}
|