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:
2026-05-11 14:28:12 +00:00
parent 1ccf2fb1cf
commit 6f32a206b4
18 changed files with 1793 additions and 194 deletions
+149
View File
@@ -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) {