Support remote project tabs with local OpenCode bridge
Keeps provider credentials local while executing OpenCode shell and file tools against the selected remote workspace over SSH.
This commit is contained in:
@@ -287,6 +287,155 @@ export class OpenCodeServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const openCodeBridgeToolNames = [
|
||||
'bash',
|
||||
'read',
|
||||
'write',
|
||||
'edit',
|
||||
'grep',
|
||||
'glob',
|
||||
'apply_patch',
|
||||
] as const;
|
||||
|
||||
export type TOpenCodeBridgeToolName = typeof openCodeBridgeToolNames[number];
|
||||
|
||||
export interface IOpenCodeBridgeConfigRenderOptions {
|
||||
bridgeUrlEnvName?: string;
|
||||
bridgeTokenEnvName?: string;
|
||||
}
|
||||
|
||||
export const renderOpenCodeBridgeConfigContent = () => `${JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
autoupdate: false,
|
||||
snapshot: false,
|
||||
permission: {
|
||||
lsp: 'deny',
|
||||
skill: 'deny',
|
||||
},
|
||||
}, undefined, 2)}\n`;
|
||||
|
||||
export const renderOpenCodeBridgeToolFiles = (options: IOpenCodeBridgeConfigRenderOptions = {}) => {
|
||||
const files: Record<string, string> = {};
|
||||
for (const toolName of openCodeBridgeToolNames) {
|
||||
files[`tools/${toolName}.js`] = renderOpenCodeBridgeToolFile(toolName, options);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
export const renderOpenCodeBridgeToolFile = (
|
||||
toolName: TOpenCodeBridgeToolName,
|
||||
options: IOpenCodeBridgeConfigRenderOptions = {},
|
||||
) => {
|
||||
const bridgeUrlEnvName = options.bridgeUrlEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_URL';
|
||||
const bridgeTokenEnvName = options.bridgeTokenEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_TOKEN';
|
||||
const definition = openCodeBridgeToolDefinitions[toolName];
|
||||
return `import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const bridgeUrlEnvName = ${JSON.stringify(bridgeUrlEnvName)};
|
||||
const bridgeTokenEnvName = ${JSON.stringify(bridgeTokenEnvName)};
|
||||
|
||||
const forwardTool = async (toolName, args, context) => {
|
||||
const bridgeUrl = process.env[bridgeUrlEnvName];
|
||||
const bridgeToken = process.env[bridgeTokenEnvName];
|
||||
if (!bridgeUrl || !bridgeToken) {
|
||||
throw new Error("Git.Zone OpenCode tool bridge is not configured.");
|
||||
}
|
||||
const response = await fetch(new URL("/tool/" + encodeURIComponent(toolName), bridgeUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"authorization": "Bearer " + bridgeToken,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
args,
|
||||
context: {
|
||||
sessionID: context.sessionID,
|
||||
messageID: context.messageID,
|
||||
agent: context.agent,
|
||||
directory: context.directory,
|
||||
worktree: context.worktree,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(responseText || ("Git.Zone OpenCode tool bridge failed with HTTP " + response.status));
|
||||
}
|
||||
return responseText ? JSON.parse(responseText) : "";
|
||||
};
|
||||
|
||||
export default tool({
|
||||
description: ${JSON.stringify(definition.description)},
|
||||
args: {
|
||||
${definition.args.map((arg) => ` ${arg.name}: ${arg.schema},`).join('\n')}
|
||||
},
|
||||
async execute(args, context) {
|
||||
return forwardTool(${JSON.stringify(toolName)}, args, context);
|
||||
},
|
||||
});
|
||||
`;
|
||||
};
|
||||
|
||||
const openCodeBridgeToolDefinitions: Record<TOpenCodeBridgeToolName, {
|
||||
description: string;
|
||||
args: Array<{ name: string; schema: string }>;
|
||||
}> = {
|
||||
bash: {
|
||||
description: 'Execute a shell command on the selected remote Git.Zone workspace over SSH. The command runs on the remote host, not on the local proxy workspace. Use workdir for a remote working directory when needed.',
|
||||
args: [
|
||||
{ name: 'command', schema: 'tool.schema.string().describe("The command to execute on the remote host")' },
|
||||
{ name: 'timeout', schema: 'tool.schema.number().optional().describe("Optional timeout in milliseconds")' },
|
||||
{ name: 'workdir', schema: 'tool.schema.string().optional().describe("Remote working directory. Defaults to the selected remote project path.")' },
|
||||
{ name: 'description', schema: 'tool.schema.string().describe("Clear, concise description of what this command does in 5-10 words")' },
|
||||
],
|
||||
},
|
||||
read: {
|
||||
description: 'Read a file or directory from the selected remote Git.Zone workspace. Paths may be remote absolute paths or paths relative to the selected remote project.',
|
||||
args: [
|
||||
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file or directory path to read")' },
|
||||
{ name: 'offset', schema: 'tool.schema.number().optional().describe("The line number to start reading from (1-indexed)")' },
|
||||
{ name: 'limit', schema: 'tool.schema.number().optional().describe("The maximum number of lines to read")' },
|
||||
],
|
||||
},
|
||||
write: {
|
||||
description: 'Create or overwrite a file in the selected remote Git.Zone workspace.',
|
||||
args: [
|
||||
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to write")' },
|
||||
{ name: 'content', schema: 'tool.schema.string().describe("The content to write to the remote file")' },
|
||||
],
|
||||
},
|
||||
edit: {
|
||||
description: 'Modify an existing remote file using exact string replacement.',
|
||||
args: [
|
||||
{ name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to modify")' },
|
||||
{ name: 'oldString', schema: 'tool.schema.string().describe("The text to replace")' },
|
||||
{ name: 'newString', schema: 'tool.schema.string().describe("The replacement text")' },
|
||||
{ name: 'replaceAll', schema: 'tool.schema.boolean().optional().describe("Replace all occurrences of oldString")' },
|
||||
],
|
||||
},
|
||||
grep: {
|
||||
description: 'Search remote file contents in the selected Git.Zone workspace using ripgrep on the remote host.',
|
||||
args: [
|
||||
{ name: 'pattern', schema: 'tool.schema.string().describe("The regex pattern to search for in file contents")' },
|
||||
{ name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory or file to search. Defaults to the selected remote project path.")' },
|
||||
{ name: 'include', schema: 'tool.schema.string().optional().describe("File glob to include, for example *.ts or *.{ts,tsx}")' },
|
||||
],
|
||||
},
|
||||
glob: {
|
||||
description: 'Find remote files by glob pattern in the selected Git.Zone workspace using ripgrep on the remote host.',
|
||||
args: [
|
||||
{ name: 'pattern', schema: 'tool.schema.string().describe("The glob pattern to match remote files against")' },
|
||||
{ name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory to search. Defaults to the selected remote project path.")' },
|
||||
],
|
||||
},
|
||||
apply_patch: {
|
||||
description: 'Apply a stripped-down file patch to the selected remote Git.Zone workspace. Patch paths are relative to the selected remote project unless absolute remote paths are supplied.',
|
||||
args: [
|
||||
{ name: 'patchText', schema: 'tool.schema.string().describe("The full patch text that describes all remote file changes to make")' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const parseServerSentEvent = (raw: string): IOpenCodeEvent | undefined => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface IRemoteServerBootstrapOptions {
|
||||
opencodePassword: string;
|
||||
installRoot?: string;
|
||||
nodeEnv?: string;
|
||||
theiaColorTheme?: string;
|
||||
}
|
||||
|
||||
export interface IRemoteEphemeralBootstrapOptions extends IRemoteServerBootstrapOptions {
|
||||
@@ -60,9 +61,36 @@ export interface IRemoteEphemeralRuntimeMarkOptions {
|
||||
nodePath?: string;
|
||||
}
|
||||
|
||||
export interface IRemoteEphemeralPortAllocationOptions {
|
||||
runtimeRoot: string;
|
||||
count?: number;
|
||||
nodePath?: string;
|
||||
}
|
||||
|
||||
export interface IRemoteProjectRegistryOptions {
|
||||
runtimeRoot: string;
|
||||
ideDataRoot?: string;
|
||||
nodePath?: string;
|
||||
}
|
||||
|
||||
export interface IRemoteProjectUpsertOptions extends IRemoteProjectRegistryOptions {
|
||||
projectPath: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface IRemoteOpenCodeToolCommandOptions {
|
||||
runtimeRoot: string;
|
||||
workspacePath: string;
|
||||
toolName: string;
|
||||
nodePath?: string;
|
||||
rgPath?: string;
|
||||
}
|
||||
|
||||
export const defaultIdeDataRoot = '~/.git.zone/ide';
|
||||
export const defaultInstallRoot = '~/.git.zone/ide/server';
|
||||
export const remoteEphemeralRuntimeMarkerFileName = '.gitzone-runtime-sha256';
|
||||
export const remoteProjectsFileName = 'projects.json';
|
||||
export const defaultTheiaColorTheme = 'dark';
|
||||
|
||||
export const createRemoteServerInstallPlan = (
|
||||
options: IRemoteServerInstallPlanOptions,
|
||||
@@ -120,6 +148,7 @@ export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOpti
|
||||
const env = {
|
||||
GITZONE_IDE_WORKSPACE: options.workspacePath,
|
||||
GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`,
|
||||
GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART: '1',
|
||||
OPENCODE_SERVER_USERNAME: options.opencodeUsername,
|
||||
OPENCODE_SERVER_PASSWORD: options.opencodePassword,
|
||||
NODE_ENV: options.nodeEnv ?? 'production',
|
||||
@@ -148,10 +177,27 @@ export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralB
|
||||
const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot;
|
||||
const logsDir = joinRemotePath(ideDataRoot, 'logs');
|
||||
const theiaConfigDir = joinRemotePath(ideDataRoot, 'theia');
|
||||
const theiaSettingsPath = joinRemotePath(theiaConfigDir, 'settings.json');
|
||||
const logFile = joinRemotePath(logsDir, `theia-${options.theiaPort}.log`);
|
||||
const theiaColorTheme = options.theiaColorTheme ?? defaultTheiaColorTheme;
|
||||
const themePreferenceScript = [
|
||||
"const fs = require('fs');",
|
||||
'const settingsPath = process.env.GITZONE_IDE_THEIA_SETTINGS;',
|
||||
'const colorTheme = process.env.GITZONE_IDE_THEIA_COLOR_THEME;',
|
||||
'let settings = {};',
|
||||
'try {',
|
||||
" const raw = fs.readFileSync(settingsPath, 'utf8').trim();",
|
||||
' settings = raw ? JSON.parse(raw) : {};',
|
||||
'} catch (error) {',
|
||||
" if (!error || error.code !== 'ENOENT') throw error;",
|
||||
'}',
|
||||
"settings['workbench.colorTheme'] = colorTheme;",
|
||||
"fs.writeFileSync(settingsPath, `${JSON.stringify(settings, undefined, 2)}\\n`);",
|
||||
].join('\n');
|
||||
const env = {
|
||||
GITZONE_IDE_WORKSPACE: options.workspacePath,
|
||||
GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`,
|
||||
GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART: '1',
|
||||
OPENCODE_SERVER_USERNAME: options.opencodeUsername,
|
||||
OPENCODE_SERVER_PASSWORD: options.opencodePassword,
|
||||
NODE_ENV: options.nodeEnv ?? 'production',
|
||||
@@ -160,11 +206,14 @@ export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralB
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`mkdir -p ${quoteRemotePath(logsDir)} ${quoteRemotePath(theiaConfigDir)}`,
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`test -x ${quoteRemotePath(nodePath)} || { printf 'bundled node not executable: %s\n' ${quoteShellArg(nodePath)} >&2; exit 1; }`,
|
||||
`test -f ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} || { printf 'bundled Theia backend missing: %s\n' ${quoteShellArg(appDir)} >&2; exit 1; }`,
|
||||
`test -d ${quoteRemotePath(options.workspacePath)} || { printf 'workspace path not found: %s\n' ${quoteShellArg(options.workspacePath)} >&2; exit 1; }`,
|
||||
`GITZONE_IDE_THEIA_SETTINGS=${quoteRemotePath(theiaSettingsPath)} GITZONE_IDE_THEIA_COLOR_THEME=${quoteShellArg(theiaColorTheme)} ${quoteRemotePath(nodePath)} <<'GITZONE_IDE_THEME'`,
|
||||
themePreferenceScript,
|
||||
'GITZONE_IDE_THEME',
|
||||
`cd ${quoteRemotePath(options.workspacePath)}`,
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`export THEIA_CONFIG_DIR=${quoteRemotePath(theiaConfigDir)}`,
|
||||
...Object.entries(env).map(([key, value]) => {
|
||||
const renderedValue = key === 'GITZONE_IDE_WORKSPACE' ? quoteRemotePath(value) : quoteShellArg(value);
|
||||
@@ -233,6 +282,107 @@ export const createRemoteEphemeralRuntimeMarkCommand = (options: IRemoteEphemera
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteEphemeralPortAllocationCommand = (options: IRemoteEphemeralPortAllocationOptions) => {
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const count = options.count ?? 1;
|
||||
const script = [
|
||||
"const net = require('net');",
|
||||
`const count = ${JSON.stringify(count)};`,
|
||||
'const ports = [];',
|
||||
'const servers = [];',
|
||||
'const listen = () => new Promise((resolve, reject) => {',
|
||||
' const server = net.createServer();',
|
||||
" server.on('error', reject);",
|
||||
" server.listen(0, '127.0.0.1', () => {",
|
||||
' ports.push(server.address().port);',
|
||||
' servers.push(server);',
|
||||
' resolve();',
|
||||
' });',
|
||||
'});',
|
||||
'(async () => {',
|
||||
' for (let index = 0; index < count; index++) await listen();',
|
||||
" console.log(`ports=${ports.join(',')}`);",
|
||||
' await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));',
|
||||
'})().catch((error) => { console.error(error.stack || String(error)); process.exit(1); });',
|
||||
].join('\n');
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`${quoteRemotePath(nodePath)} -e ${quoteShellArg(script)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteOpenCodeToolCommand = (options: IRemoteOpenCodeToolCommandOptions) => {
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const rgPath = options.rgPath ?? joinRemotePath(options.runtimeRoot, 'applications/remote-theia/lib/backend/native/rg');
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`export GITZONE_IDE_WORKSPACE=${quoteRemotePath(options.workspacePath)}`,
|
||||
`export GITZONE_IDE_TOOL_NAME=${quoteShellArg(options.toolName)}`,
|
||||
`export GITZONE_IDE_RG_PATH=${quoteRemotePath(rgPath)}`,
|
||||
`${quoteRemotePath(nodePath)} -e ${quoteShellArg(remoteOpenCodeToolScript)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteProjectListCommand = (options: IRemoteProjectRegistryOptions) => {
|
||||
const projectsFile = joinRemotePath(options.ideDataRoot ?? defaultIdeDataRoot, remoteProjectsFileName);
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`if test -f ${quoteRemotePath(projectsFile)}; then`,
|
||||
` cat ${quoteRemotePath(projectsFile)}`,
|
||||
'else',
|
||||
" printf '{\"projects\":[]}\n'",
|
||||
'fi',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteProjectUpsertCommand = (options: IRemoteProjectUpsertOptions) => {
|
||||
const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot;
|
||||
const projectsFile = joinRemotePath(ideDataRoot, remoteProjectsFileName);
|
||||
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
|
||||
const script = [
|
||||
"const crypto = require('crypto');",
|
||||
"const fs = require('fs');",
|
||||
"const path = require('path');",
|
||||
'const projectsFile = process.env.GITZONE_IDE_PROJECTS_FILE;',
|
||||
'const projectPath = process.env.GITZONE_IDE_PROJECT_PATH;',
|
||||
'const title = process.env.GITZONE_IDE_PROJECT_TITLE || path.basename(projectPath) || projectPath;',
|
||||
"let registry = { projects: [] };",
|
||||
"try { registry = JSON.parse(fs.readFileSync(projectsFile, 'utf8')); } catch {}",
|
||||
"if (!Array.isArray(registry.projects)) registry.projects = [];",
|
||||
"const id = crypto.createHash('sha256').update(projectPath).digest('hex').slice(0, 16);",
|
||||
'const now = new Date().toISOString();',
|
||||
'const existing = registry.projects.find((project) => project.id === id || project.path === projectPath);',
|
||||
'if (existing) {',
|
||||
' existing.id = id;',
|
||||
' existing.path = projectPath;',
|
||||
' existing.title = title;',
|
||||
' existing.updatedAt = now;',
|
||||
'} else {',
|
||||
' registry.projects.push({ id, path: projectPath, title, createdAt: now, updatedAt: now });',
|
||||
'}',
|
||||
'registry.projects.sort((left, right) => left.title.localeCompare(right.title));',
|
||||
'fs.mkdirSync(path.dirname(projectsFile), { recursive: true });',
|
||||
"fs.writeFileSync(`${projectsFile}.tmp`, `${JSON.stringify(registry, undefined, 2)}\\n`);",
|
||||
"fs.renameSync(`${projectsFile}.tmp`, projectsFile);",
|
||||
'console.log(JSON.stringify(registry));',
|
||||
].join('\n');
|
||||
|
||||
return [
|
||||
'set -euo pipefail',
|
||||
`export GITZONE_IDE_PROJECTS_FILE=${quoteRemotePath(projectsFile)}`,
|
||||
`export GITZONE_IDE_PROJECT_PATH=${quoteRemotePath(options.projectPath)}`,
|
||||
`export GITZONE_IDE_PROJECT_TITLE=${quoteShellArg(options.title ?? '')}`,
|
||||
`test -d "$GITZONE_IDE_PROJECT_PATH" || { printf 'workspace path not found: %s\n' "$GITZONE_IDE_PROJECT_PATH" >&2; exit 1; }`,
|
||||
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
|
||||
`${quoteRemotePath(nodePath)} -e ${quoteShellArg(script)}`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const createRemoteHealthCommand = (serverVersion: string, installRoot = defaultInstallRoot) => {
|
||||
const plan = createRemoteServerInstallPlan({
|
||||
serverVersion,
|
||||
@@ -246,6 +396,199 @@ export const createRemoteHealthCommand = (serverVersion: string, installRoot = d
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const remoteOpenCodeToolScript = [
|
||||
"const fs = require('fs');",
|
||||
"const path = require('path');",
|
||||
"const childProcess = require('child_process');",
|
||||
"const toolName = process.env.GITZONE_IDE_TOOL_NAME;",
|
||||
"const workspacePath = expandHome(process.env.GITZONE_IDE_WORKSPACE || process.cwd());",
|
||||
"const rgPath = process.env.GITZONE_IDE_RG_PATH || 'rg';",
|
||||
"const inputText = fs.readFileSync(0, 'utf8');",
|
||||
"const request = inputText.trim() ? JSON.parse(inputText) : {};",
|
||||
"const args = request.args || {};",
|
||||
"const MAX_LINES = 2000;",
|
||||
"const MAX_LINE_LENGTH = 2000;",
|
||||
"const MAX_BYTES = 50 * 1024;",
|
||||
"function expandHome(value) {",
|
||||
" const text = String(value || '');",
|
||||
" if (text === '~' || text === '$HOME') return process.env.HOME || text;",
|
||||
" if (text.startsWith('~/')) return path.join(process.env.HOME || '', text.slice(2));",
|
||||
" if (text.startsWith('$HOME/')) return path.join(process.env.HOME || '', text.slice(6));",
|
||||
" return text;",
|
||||
"}",
|
||||
"function normalizeRemotePath(value, fallback) {",
|
||||
" if (!value) return fallback;",
|
||||
" const expanded = expandHome(value);",
|
||||
" return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(fallback, expanded);",
|
||||
"}",
|
||||
"function limitLine(line) {",
|
||||
" return line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + '... (line truncated to 2000 chars)' : line;",
|
||||
"}",
|
||||
"function writeJson(result) { process.stdout.write(JSON.stringify(result)); }",
|
||||
"function ensureParent(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); }",
|
||||
"function toLines(text) {",
|
||||
" if (!text) return [];",
|
||||
" const lines = text.split(/\\r?\\n/);",
|
||||
" if (text.endsWith('\\n')) lines.pop();",
|
||||
" return lines;",
|
||||
"}",
|
||||
"function readTool() {",
|
||||
" const filePath = normalizeRemotePath(args.filePath, workspacePath);",
|
||||
" const stat = fs.statSync(filePath);",
|
||||
" const offset = Math.max(1, Number(args.offset || 1));",
|
||||
" const limit = Math.max(0, Number(args.limit || MAX_LINES));",
|
||||
" if (stat.isDirectory()) {",
|
||||
" const entries = fs.readdirSync(filePath, { withFileTypes: true }).map((entry) => entry.name + (entry.isDirectory() ? '/' : '')).sort((a, b) => a.localeCompare(b));",
|
||||
" const sliced = entries.slice(offset - 1, offset - 1 + limit);",
|
||||
" const truncated = offset - 1 + sliced.length < entries.length;",
|
||||
" return { output: ['<path>' + filePath + '</path>', '<type>directory</type>', '<entries>', sliced.join('\\n'), truncated ? '\\n(Showing ' + sliced.length + ' of ' + entries.length + ' entries. Use offset=' + (offset + sliced.length) + ' to continue.)' : '\\n(' + entries.length + ' entries)', '</entries>'].join('\\n'), metadata: { preview: sliced.slice(0, 20).join('\\n'), truncated } };",
|
||||
" }",
|
||||
" const buffer = fs.readFileSync(filePath);",
|
||||
" if (buffer.includes(0)) throw new Error('Cannot read binary file: ' + filePath);",
|
||||
" const lines = toLines(buffer.toString('utf8'));",
|
||||
" if (offset > lines.length && !(lines.length === 0 && offset === 1)) throw new Error('Offset ' + offset + ' is out of range for this file (' + lines.length + ' lines)');",
|
||||
" const raw = [];",
|
||||
" let bytes = 0;",
|
||||
" let cut = false;",
|
||||
" for (const line of lines.slice(offset - 1)) {",
|
||||
" if (raw.length >= limit) break;",
|
||||
" const next = limitLine(line);",
|
||||
" const size = Buffer.byteLength(next, 'utf8') + (raw.length > 0 ? 1 : 0);",
|
||||
" if (bytes + size > MAX_BYTES) { cut = true; break; }",
|
||||
" raw.push(next);",
|
||||
" bytes += size;",
|
||||
" }",
|
||||
" const last = offset + raw.length - 1;",
|
||||
" const more = cut || last < lines.length;",
|
||||
" let output = '<path>' + filePath + '</path>\\n<type>file</type>\\n<content>\\n';",
|
||||
" output += raw.map((line, index) => (index + offset) + ': ' + line).join('\\n');",
|
||||
" output += more ? '\\n\\n(Showing lines ' + offset + '-' + last + ' of ' + lines.length + '. Use offset=' + (last + 1) + ' to continue.)' : '\\n\\n(End of file - total ' + lines.length + ' lines)';",
|
||||
" output += '\\n</content>';",
|
||||
" return { output, metadata: { preview: raw.slice(0, 20).join('\\n'), truncated: more } };",
|
||||
"}",
|
||||
"function writeTool() {",
|
||||
" const filePath = normalizeRemotePath(args.filePath, workspacePath);",
|
||||
" const existed = fs.existsSync(filePath);",
|
||||
" ensureParent(filePath);",
|
||||
" fs.writeFileSync(filePath, String(args.content || ''));",
|
||||
" return { output: 'Wrote file successfully.', metadata: { filepath: filePath, exists: existed } };",
|
||||
"}",
|
||||
"function editTool() {",
|
||||
" const filePath = normalizeRemotePath(args.filePath, workspacePath);",
|
||||
" if (args.oldString === args.newString) throw new Error('No changes to apply: oldString and newString are identical.');",
|
||||
" let content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';",
|
||||
" let next;",
|
||||
" if (args.oldString === '') {",
|
||||
" next = String(args.newString || '');",
|
||||
" } else {",
|
||||
" const oldString = String(args.oldString || '');",
|
||||
" const newString = String(args.newString || '');",
|
||||
" const matches = content.split(oldString).length - 1;",
|
||||
" if (matches === 0) throw new Error('Could not find oldString in the file. It must match exactly.');",
|
||||
" if (!args.replaceAll && matches > 1) throw new Error('Found multiple matches for oldString. Provide more surrounding context or set replaceAll.');",
|
||||
" next = args.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);",
|
||||
" }",
|
||||
" ensureParent(filePath);",
|
||||
" fs.writeFileSync(filePath, next);",
|
||||
" return { output: 'Edit applied successfully.', metadata: { filepath: filePath } };",
|
||||
"}",
|
||||
"function bashTool() {",
|
||||
" const cwd = normalizeRemotePath(args.workdir, workspacePath);",
|
||||
" const timeout = Number(args.timeout || 120000);",
|
||||
" const shell = process.env.SHELL || '/bin/sh';",
|
||||
" const result = childProcess.spawnSync(shell, ['-lc', String(args.command || '')], { cwd, encoding: 'utf8', timeout, maxBuffer: 10 * 1024 * 1024 });",
|
||||
" const outputText = [result.stdout || '', result.stderr || ''].filter(Boolean).join('');",
|
||||
" const metadata = [];",
|
||||
" if (result.error && result.error.code === 'ETIMEDOUT') metadata.push('remote shell tool terminated command after exceeding timeout ' + timeout + ' ms.');",
|
||||
" metadata.push('exit=' + (result.status === null || result.status === undefined ? 1 : result.status));",
|
||||
" const output = (outputText.trim() ? outputText.replace(/\\s+$/g, '') : '(no output)') + '\\n\\n<shell_metadata>\\n' + metadata.join('\\n') + '\\n</shell_metadata>';",
|
||||
" return { output, metadata: { exit: result.status, cwd } };",
|
||||
"}",
|
||||
"function grepTool() {",
|
||||
" if (!args.pattern) throw new Error('pattern is required');",
|
||||
" const search = normalizeRemotePath(args.path, workspacePath);",
|
||||
" const executable = fs.existsSync(rgPath) ? rgPath : 'rg';",
|
||||
" const rgArgs = ['--line-number', '--with-filename', '--color', 'never', '--no-heading'];",
|
||||
" if (args.include) rgArgs.push('--glob', String(args.include));",
|
||||
" rgArgs.push('--', String(args.pattern), search);",
|
||||
" const result = childProcess.spawnSync(executable, rgArgs, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });",
|
||||
" if (result.status !== 0 && result.status !== 1) throw new Error(result.stderr || ('ripgrep failed with exit ' + result.status));",
|
||||
" const rows = (result.stdout || '').split(/\\r?\\n/).filter(Boolean).map((line) => { const match = line.match(/^(.*?):(\\d+):(.*)$/); if (!match) return undefined; const filePath = path.resolve(match[1]); let mtime = 0; try { mtime = fs.statSync(filePath).mtimeMs; } catch {} return { path: filePath, line: Number(match[2]), text: match[3], mtime }; }).filter(Boolean);",
|
||||
" rows.sort((left, right) => right.mtime - left.mtime);",
|
||||
" const limit = 100;",
|
||||
" const truncated = rows.length > limit;",
|
||||
" const finalRows = truncated ? rows.slice(0, limit) : rows;",
|
||||
" if (finalRows.length === 0) return { output: 'No files found', metadata: { matches: 0, truncated: false } };",
|
||||
" const output = ['Found ' + rows.length + ' matches' + (truncated ? ' (showing first ' + limit + ')' : '')];",
|
||||
" let current = '';",
|
||||
" for (const row of finalRows) { if (current !== row.path) { if (current) output.push(''); current = row.path; output.push(row.path + ':'); } output.push(' Line ' + row.line + ': ' + limitLine(row.text)); }",
|
||||
" if (truncated) output.push('', '(Results truncated: showing ' + limit + ' of ' + rows.length + ' matches.)');",
|
||||
" return { output: output.join('\\n'), metadata: { matches: rows.length, truncated } };",
|
||||
"}",
|
||||
"function globTool() {",
|
||||
" if (!args.pattern) throw new Error('pattern is required');",
|
||||
" const search = normalizeRemotePath(args.path, workspacePath);",
|
||||
" const executable = fs.existsSync(rgPath) ? rgPath : 'rg';",
|
||||
" const result = childProcess.spawnSync(executable, ['--files', '--color', 'never', '--glob', String(args.pattern), '--', search], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });",
|
||||
" if (result.status !== 0 && result.status !== 1) throw new Error(result.stderr || ('ripgrep failed with exit ' + result.status));",
|
||||
" const files = (result.stdout || '').split(/\\r?\\n/).filter(Boolean).map((entry) => path.isAbsolute(entry) ? entry : path.resolve(search, entry)).map((filePath) => { let mtime = 0; try { mtime = fs.statSync(filePath).mtimeMs; } catch {} return { path: filePath, mtime }; }).sort((left, right) => right.mtime - left.mtime);",
|
||||
" const limit = 100;",
|
||||
" const truncated = files.length > limit;",
|
||||
" const finalFiles = truncated ? files.slice(0, limit) : files;",
|
||||
" const output = finalFiles.length ? finalFiles.map((file) => file.path) : ['No files found'];",
|
||||
" if (truncated) output.push('', '(Results are truncated: showing first ' + limit + ' results.)');",
|
||||
" return { output: output.join('\\n'), metadata: { count: finalFiles.length, truncated } };",
|
||||
"}",
|
||||
"function parsePatch(text) {",
|
||||
" const lines = String(text || '').replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').split('\\n');",
|
||||
" if (lines[0] !== '*** Begin Patch') throw new Error('apply_patch verification failed: missing begin marker');",
|
||||
" const ops = [];",
|
||||
" let index = 1;",
|
||||
" while (index < lines.length) {",
|
||||
" const line = lines[index];",
|
||||
" if (line === '*** End Patch') break;",
|
||||
" if (line.startsWith('*** Add File: ')) { const file = line.slice(14); const body = []; index++; while (index < lines.length && !lines[index].startsWith('*** ')) { if (!lines[index].startsWith('+')) throw new Error('add file lines must start with +'); body.push(lines[index].slice(1)); index++; } ops.push({ type: 'add', file, body }); continue; }",
|
||||
" if (line.startsWith('*** Delete File: ')) { ops.push({ type: 'delete', file: line.slice(17) }); index++; continue; }",
|
||||
" if (line.startsWith('*** Update File: ')) { const file = line.slice(17); const body = []; let moveTo; index++; if (lines[index] && lines[index].startsWith('*** Move to: ')) { moveTo = lines[index].slice(13); index++; } while (index < lines.length && !lines[index].startsWith('*** ')) { body.push(lines[index]); index++; } ops.push({ type: 'update', file, moveTo, body }); continue; }",
|
||||
" throw new Error('apply_patch verification failed: unknown patch line ' + line);",
|
||||
" }",
|
||||
" if (ops.length === 0) throw new Error('patch rejected: empty patch');",
|
||||
" return ops;",
|
||||
"}",
|
||||
"function replaceHunk(content, oldLines, newLines) {",
|
||||
" const oldText = oldLines.join('\\n');",
|
||||
" const newText = newLines.join('\\n');",
|
||||
" const candidates = oldLines.length === 0 ? [['', newText]] : [[oldText + '\\n', newText + '\\n'], [oldText, newText]];",
|
||||
" for (const pair of candidates) { const from = pair[0]; const to = pair[1]; const position = from === '' ? content.length : content.indexOf(from); if (position !== -1) return content.slice(0, position) + to + content.slice(position + from.length); }",
|
||||
" throw new Error('apply_patch verification failed: hunk context not found');",
|
||||
"}",
|
||||
"function applyPatchTool() {",
|
||||
" const ops = parsePatch(args.patchText);",
|
||||
" const summary = [];",
|
||||
" for (const op of ops) {",
|
||||
" const filePath = normalizeRemotePath(op.file, workspacePath);",
|
||||
" if (op.type === 'add') { ensureParent(filePath); fs.writeFileSync(filePath, op.body.join('\\n') + (op.body.length ? '\\n' : '')); summary.push('A ' + path.relative(workspacePath, filePath)); continue; }",
|
||||
" if (op.type === 'delete') { fs.rmSync(filePath, { force: true }); summary.push('D ' + path.relative(workspacePath, filePath)); continue; }",
|
||||
" let content = fs.readFileSync(filePath, 'utf8');",
|
||||
" const groups = []; let current = [];",
|
||||
" for (const line of op.body) { if (line.startsWith('@@')) { if (current.length) groups.push(current); current = []; continue; } current.push(line); }",
|
||||
" if (current.length) groups.push(current);",
|
||||
" for (const group of groups) { const oldLines = []; const newLines = []; for (const line of group) { if (!line) continue; const marker = line[0]; const value = line.slice(1); if (marker === ' ') { oldLines.push(value); newLines.push(value); } else if (marker === '-') { oldLines.push(value); } else if (marker === '+') { newLines.push(value); } else if (line.startsWith('\\\\ No newline')) { } else { throw new Error('apply_patch verification failed: invalid hunk line ' + line); } } content = replaceHunk(content, oldLines, newLines); }",
|
||||
" const targetPath = op.moveTo ? normalizeRemotePath(op.moveTo, workspacePath) : filePath;",
|
||||
" ensureParent(targetPath); fs.writeFileSync(targetPath, content); if (op.moveTo) fs.rmSync(filePath, { force: true }); summary.push('M ' + path.relative(workspacePath, targetPath));",
|
||||
" }",
|
||||
" return { output: 'Success. Updated the following files:\\n' + summary.join('\\n'), metadata: { files: summary } };",
|
||||
"}",
|
||||
"try {",
|
||||
" const handlers = { bash: bashTool, read: readTool, write: writeTool, edit: editTool, grep: grepTool, glob: globTool, apply_patch: applyPatchTool };",
|
||||
" if (!handlers[toolName]) throw new Error('Unsupported Git.Zone OpenCode tool: ' + toolName);",
|
||||
" writeJson(handlers[toolName]());",
|
||||
"} catch (error) {",
|
||||
" console.error(error && error.stack ? error.stack : String(error));",
|
||||
" process.exit(1);",
|
||||
"}",
|
||||
].join('\n');
|
||||
|
||||
export const quoteShellArg = (value: string | number | boolean) => {
|
||||
const stringValue = String(value);
|
||||
if (stringValue.length === 0) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ISshRunOptions {
|
||||
batchMode?: boolean;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stdin?: string | Buffer;
|
||||
}
|
||||
|
||||
export interface ISshRunResult {
|
||||
@@ -232,7 +233,7 @@ export const runSshCommand = async (
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: options.stdin === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
@@ -247,8 +248,11 @@ export const runSshCommand = async (
|
||||
}, options.timeoutMs)
|
||||
: undefined;
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
if (options.stdin !== undefined) {
|
||||
child.stdin!.end(options.stdin);
|
||||
}
|
||||
child.on('error', (error) => {
|
||||
finished = true;
|
||||
if (timeout) {
|
||||
|
||||
Reference in New Issue
Block a user