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
+95
View File
@@ -1,9 +1,18 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import {
createRemoteEphemeralBootstrapCommand,
createRemoteEphemeralReadinessCommand,
createRemoteEphemeralPortAllocationCommand,
createRemoteEphemeralRuntimeCacheCheckCommand,
createRemoteEphemeralRuntimeMarkCommand,
createRemoteOpenCodeToolCommand,
createRemoteProjectListCommand,
createRemoteProjectUpsertCommand,
remoteOpenCodeToolScript,
createRemoteBootstrapCommand,
createRemoteInstallCommand,
createRemoteServerInstallPlan,
@@ -51,6 +60,7 @@ tap.test('should render install and bootstrap commands', async () => {
expect(installCommand).toInclude('GITZONE_IDE_MANIFEST');
expect(installCommand).toInclude('"$HOME"/\'.git.zone/ide/server/0.1.0\'');
expect(bootstrapCommand).toInclude('GITZONE_IDE_OPENCODE_PORT');
expect(bootstrapCommand).toInclude('GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART');
expect(bootstrapCommand).toInclude('pnpm --dir');
});
@@ -85,6 +95,9 @@ tap.test('should render ephemeral runtime bootstrap without remote pnpm', async
expect(bootstrapCommand).not.toInclude('pnpm');
expect(bootstrapCommand).toInclude('LD_LIBRARY_PATH');
expect(bootstrapCommand).toInclude('THEIA_CONFIG_DIR="$HOME"/\'.git.zone/ide/theia\'');
expect(bootstrapCommand).toInclude('GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART=\'1\'');
expect(bootstrapCommand).toInclude('GITZONE_IDE_THEIA_COLOR_THEME=\'dark\'');
expect(bootstrapCommand).toInclude("settings['workbench.colorTheme'] = colorTheme");
expect(bootstrapCommand).toInclude('"$HOME"/\'.git.zone/ide/logs\'');
expect(bootstrapCommand).toInclude('runtimeRoot=');
});
@@ -126,4 +139,86 @@ tap.test('should render ephemeral runtime mark command', async () => {
expect(markCommand).toInclude('runtimeCache=stored');
});
tap.test('should render remote port allocation command', async () => {
const portCommand = createRemoteEphemeralPortAllocationCommand({
runtimeRoot: '/tmp/gitzone-ide-runtime-test',
count: 2,
});
expect(portCommand).toInclude('/tmp/gitzone-ide-runtime-test/node/bin/node');
expect(portCommand).toInclude('ports=');
expect(portCommand).toInclude('LD_LIBRARY_PATH');
});
tap.test('should render remote OpenCode tool bridge command', async () => {
const command = createRemoteOpenCodeToolCommand({
runtimeRoot: '/tmp/gitzone-ide-runtime-test',
workspacePath: '$HOME/project',
toolName: 'read',
});
expect(command).toInclude('/tmp/gitzone-ide-runtime-test/node/bin/node');
expect(command).toInclude('GITZONE_IDE_TOOL_NAME=\'read\'');
expect(command).toInclude('GITZONE_IDE_WORKSPACE="$HOME"/\'project\'');
expect(command).toInclude('GITZONE_IDE_RG_PATH');
expect(command).toInclude('fs.readFileSync(0');
});
tap.test('should execute remote OpenCode tool script with stdin payloads', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitzone-opencode-tool-'));
const filePath = path.join(tempDir, 'sample.txt');
await fs.writeFile(filePath, 'hello\nworld\n');
const readResult = runRemoteOpenCodeToolScript('read', tempDir, { filePath: 'sample.txt' });
expect(readResult.output).toInclude('1: hello');
const editResult = runRemoteOpenCodeToolScript('edit', tempDir, {
filePath: 'sample.txt',
oldString: 'world',
newString: 'remote',
});
expect(editResult.output).toInclude('Edit applied successfully');
expect(await fs.readFile(filePath, 'utf8')).toEqual('hello\nremote\n');
const patchResult = runRemoteOpenCodeToolScript('apply_patch', tempDir, {
patchText: '*** Begin Patch\n*** Add File: nested/new.txt\n+created remotely\n*** End Patch',
});
expect(patchResult.output).toInclude('A nested/new.txt');
expect(await fs.readFile(path.join(tempDir, 'nested', 'new.txt'), 'utf8')).toEqual('created remotely\n');
});
tap.test('should render remote project registry commands', async () => {
const listCommand = createRemoteProjectListCommand({
runtimeRoot: '/tmp/gitzone-ide-runtime-test',
});
const upsertCommand = createRemoteProjectUpsertCommand({
runtimeRoot: '/tmp/gitzone-ide-runtime-test',
projectPath: '$HOME/project',
title: 'Project',
});
expect(listCommand).toInclude('projects.json');
expect(listCommand).toInclude('{"projects":[]}');
expect(upsertCommand).toInclude('GITZONE_IDE_PROJECT_PATH="$HOME"/\'project\'');
expect(upsertCommand).toInclude('test -d "$GITZONE_IDE_PROJECT_PATH"');
expect(upsertCommand).toInclude('crypto.createHash');
});
const runRemoteOpenCodeToolScript = (toolName: string, workspacePath: string, args: Record<string, unknown>) => {
const result = childProcess.spawnSync(process.execPath, ['-e', remoteOpenCodeToolScript], {
input: JSON.stringify({ args }),
encoding: 'utf8',
env: {
...process.env,
GITZONE_IDE_TOOL_NAME: toolName,
GITZONE_IDE_WORKSPACE: workspacePath,
GITZONE_IDE_RG_PATH: '/not-found/rg',
},
});
if (result.status !== 0) {
throw new Error(result.stderr || `tool script failed with ${result.status}`);
}
return JSON.parse(result.stdout) as { output: string; metadata?: Record<string, unknown> };
};
export default tap.start();
+39
View File
@@ -0,0 +1,39 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
openCodeBridgeToolNames,
renderOpenCodeBridgeConfigContent,
renderOpenCodeBridgeToolFile,
renderOpenCodeBridgeToolFiles,
} from '../packages/opencode-bridge/ts/index.js';
tap.test('should render managed OpenCode bridge config', async () => {
const config = JSON.parse(renderOpenCodeBridgeConfigContent());
expect(config.snapshot).toEqual(false);
expect(config.autoupdate).toEqual(false);
expect(config.permission.lsp).toEqual('deny');
expect(config.permission.skill).toEqual('deny');
});
tap.test('should render tool overrides for remote bridge', async () => {
const files = renderOpenCodeBridgeToolFiles();
for (const toolName of openCodeBridgeToolNames) {
expect(files[`tools/${toolName}.js`]).toInclude(`forwardTool(${JSON.stringify(toolName)}`);
}
expect(files['tools/bash.js']).toInclude('GITZONE_IDE_TOOL_BRIDGE_URL');
expect(files['tools/apply_patch.js']).toInclude('patchText');
});
tap.test('should allow custom bridge environment names', async () => {
const toolFile = renderOpenCodeBridgeToolFile('read', {
bridgeUrlEnvName: 'CUSTOM_BRIDGE_URL',
bridgeTokenEnvName: 'CUSTOM_BRIDGE_TOKEN',
});
expect(toolFile).toInclude('CUSTOM_BRIDGE_URL');
expect(toolFile).toInclude('CUSTOM_BRIDGE_TOKEN');
expect(toolFile).toInclude('filePath');
});
export default tap.start();
+2
View File
@@ -8,7 +8,9 @@ tap.test('should keep Theia backend config under Git.Zone IDE home path', async
const packageJsonPath = path.join(process.cwd(), 'applications', 'remote-theia', 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
expect(packageJson.theia.backend.config.singleInstance).toEqual(false);
expect(packageJson.theia.backend.config.configurationFolder).toEqual('.git.zone/ide/theia');
expect(packageJson.theia.frontend.config.defaultTheme).toEqual('dark');
});
tap.test('should avoid legacy .theia workspace preference folders', async () => {