opencode-bifrost 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/assets/demo.gif +0 -0
  4. package/bun.lock +34 -0
  5. package/dist/config.d.ts +13 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/hooks.d.ts +2 -0
  8. package/dist/hooks.d.ts.map +1 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +26655 -0
  12. package/dist/manager.d.ts +54 -0
  13. package/dist/manager.d.ts.map +1 -0
  14. package/dist/security.d.ts +8 -0
  15. package/dist/security.d.ts.map +1 -0
  16. package/dist/tools/connect.d.ts +3 -0
  17. package/dist/tools/connect.d.ts.map +1 -0
  18. package/dist/tools/disconnect.d.ts +3 -0
  19. package/dist/tools/disconnect.d.ts.map +1 -0
  20. package/dist/tools/download.d.ts +3 -0
  21. package/dist/tools/download.d.ts.map +1 -0
  22. package/dist/tools/exec.d.ts +3 -0
  23. package/dist/tools/exec.d.ts.map +1 -0
  24. package/dist/tools/status.d.ts +3 -0
  25. package/dist/tools/status.d.ts.map +1 -0
  26. package/dist/tools/upload.d.ts +3 -0
  27. package/dist/tools/upload.d.ts.map +1 -0
  28. package/package.json +47 -0
  29. package/src/config.ts +151 -0
  30. package/src/hooks.ts +1 -0
  31. package/src/index.ts +64 -0
  32. package/src/manager.ts +471 -0
  33. package/src/security.ts +98 -0
  34. package/src/tools/connect.ts +35 -0
  35. package/src/tools/disconnect.ts +30 -0
  36. package/src/tools/download.ts +51 -0
  37. package/src/tools/exec.ts +68 -0
  38. package/src/tools/status.ts +49 -0
  39. package/src/tools/upload.ts +56 -0
  40. package/test/config.test.ts +233 -0
  41. package/test/integration.test.ts +199 -0
  42. package/test/manager.test.ts +209 -0
  43. package/test/security.test.ts +245 -0
  44. package/tsconfig.json +27 -0
@@ -0,0 +1,68 @@
1
+ import { homedir } from "os";
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import { bifrostManager } from "../manager";
4
+ import { validatePath, validateCommand } from "../security";
5
+
6
+ export const bifrost_exec: ToolDefinition = tool({
7
+ description:
8
+ "Execute a command on the remote server via the persistent Bifrost SSH connection. Requires bifrost_connect to be called first (or auto-connects).",
9
+ args: {
10
+ command: tool.schema
11
+ .string()
12
+ .describe("The shell command to execute remotely"),
13
+ cwd: tool.schema
14
+ .string()
15
+ .optional()
16
+ .describe("Working directory on remote server"),
17
+ timeout: tool.schema
18
+ .number()
19
+ .int()
20
+ .default(30000)
21
+ .describe("Command timeout in milliseconds (default: 30000)"),
22
+ },
23
+ execute: async (args) => {
24
+ try {
25
+ // Auto-ensure connected with config
26
+ const configPath = `${homedir()}/.config/opencode/bifrost.json`;
27
+ if (!bifrostManager.config) {
28
+ bifrostManager.loadConfig(configPath);
29
+ }
30
+
31
+ await bifrostManager.ensureConnected();
32
+
33
+ const cmdValidation = validateCommand(args.command);
34
+ if (!cmdValidation.valid) {
35
+ return `Error: ${cmdValidation.error}`;
36
+ }
37
+
38
+ let builtCommand = args.command;
39
+ if (args.cwd) {
40
+ const cwdValidation = validatePath(args.cwd, "cwd");
41
+ if (!cwdValidation.valid) {
42
+ return `Error: ${cwdValidation.error}`;
43
+ }
44
+ // Escape single quotes in cwd for safe shell usage
45
+ const escapedCwd = args.cwd.replace(/'/g, "'\\''");
46
+ builtCommand = `cd '${escapedCwd}' && ${args.command}`;
47
+ }
48
+
49
+ const result = await bifrostManager.exec(builtCommand, {
50
+ timeout: args.timeout,
51
+ maxOutputBytes: 10 * 1024 * 1024,
52
+ });
53
+
54
+ let output = `📡 Remote exec: ${args.command}\nExit code: ${result.exitCode}\n\nstdout:\n${result.stdout}`;
55
+
56
+ if (result.stderr) {
57
+ output += `\n\nstderr:\n${result.stderr}`;
58
+ }
59
+
60
+ return output;
61
+ } catch (error) {
62
+ if (error instanceof Error) {
63
+ return `Error: ${error.message}`;
64
+ }
65
+ return "Error: Unknown error occurred during remote execution";
66
+ }
67
+ },
68
+ });
@@ -0,0 +1,49 @@
1
+ import { homedir } from "os";
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import { bifrostManager } from "../manager";
4
+ import { existsSync } from "fs";
5
+
6
+ export const bifrost_status: ToolDefinition = tool({
7
+ description:
8
+ "Check the status of the Bifrost SSH connection. Shows whether connected, server details, and connection health.",
9
+ args: {},
10
+ execute: async () => {
11
+ try {
12
+ const configPath = `${homedir()}/.config/opencode/bifrost.json`;
13
+
14
+ if (!existsSync(configPath)) {
15
+ return `🌈 Bifrost Status\nState: Disconnected\nError: No configuration found at ${configPath}`;
16
+ }
17
+
18
+ if (!bifrostManager.config) {
19
+ bifrostManager.loadConfig(configPath);
20
+ }
21
+
22
+ const connected = await bifrostManager.isConnected();
23
+ const config = bifrostManager.config;
24
+ const socketDir = bifrostManager.socketDir;
25
+
26
+ if (!config) {
27
+ return `🌈 Bifrost Status\nState: Disconnected\nError: Failed to load configuration`;
28
+ }
29
+
30
+ const socketPath = `${socketDir}/%C`;
31
+ const state = connected ? "Connected" : "Disconnected";
32
+ const server = `${config.user}@${config.host}:${config.port}`;
33
+ const socketStatus = connected ? "exists" : "missing";
34
+
35
+ let output = `🌈 Bifrost Status\nState: ${state}\nServer: ${server}\nSocket: ${socketPath} (${socketStatus})`;
36
+
37
+ if (connected) {
38
+ output += "\nHealth: Connection alive";
39
+ }
40
+
41
+ return output;
42
+ } catch (error) {
43
+ if (error instanceof Error) {
44
+ return `Error: ${error.message}`;
45
+ }
46
+ return "Error: Unknown error occurred while checking status";
47
+ }
48
+ },
49
+ });
@@ -0,0 +1,56 @@
1
+ import { existsSync, statSync } from "fs";
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import { bifrostManager } from "../manager";
4
+ import { validatePath } from "../security";
5
+
6
+ export const bifrost_upload: ToolDefinition = tool({
7
+ description:
8
+ "Upload a local file to the remote server via the persistent Bifrost connection.",
9
+ args: {
10
+ localPath: tool.schema
11
+ .string()
12
+ .describe("Local file path to upload"),
13
+ remotePath: tool.schema
14
+ .string()
15
+ .describe("Remote file path destination"),
16
+ },
17
+ execute: async (args) => {
18
+ try {
19
+ const localValidation = validatePath(args.localPath, "localPath");
20
+ if (!localValidation.valid) {
21
+ return `Error: ${localValidation.error}`;
22
+ }
23
+
24
+ const remoteValidation = validatePath(args.remotePath, "remotePath");
25
+ if (!remoteValidation.valid) {
26
+ return `Error: ${remoteValidation.error}`;
27
+ }
28
+
29
+ if (!existsSync(args.localPath)) {
30
+ return `Error: Local file not found: ${args.localPath}`;
31
+ }
32
+
33
+ await bifrostManager.ensureConnected();
34
+
35
+ // Get file size before upload
36
+ const stat = statSync(args.localPath);
37
+ const fileSize = stat.size;
38
+
39
+ // Upload
40
+ await bifrostManager.upload(args.localPath, args.remotePath);
41
+
42
+ // Get config for display
43
+ const config = bifrostManager.config;
44
+ if (!config) {
45
+ return "Error: No config loaded";
46
+ }
47
+
48
+ return `📤 Uploaded ${args.localPath} → ${config.user}@${config.host}:${args.remotePath} (${fileSize} bytes)`;
49
+ } catch (error) {
50
+ if (error instanceof Error) {
51
+ return `Error: ${error.message}`;
52
+ }
53
+ return "Error: Unknown error occurred during upload";
54
+ }
55
+ },
56
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { BifrostConfigSchema, parseConfig } from "../src/config";
3
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, chmodSync } from "fs";
4
+ import { tmpdir, homedir } from "os";
5
+ import { join } from "path";
6
+
7
+ describe("BifrostConfigSchema", () => {
8
+ describe("valid config parsing", () => {
9
+ it("parses config with all fields", () => {
10
+ const input = {
11
+ host: "192.168.1.100",
12
+ user: "admin",
13
+ keyPath: "/path/to/key",
14
+ port: 2222,
15
+ connectTimeout: 30,
16
+ controlPersist: "30m",
17
+ serverAliveInterval: 60,
18
+ };
19
+
20
+ const result = BifrostConfigSchema.parse(input);
21
+
22
+ expect(result.host).toBe("192.168.1.100");
23
+ expect(result.user).toBe("admin");
24
+ expect(result.keyPath).toBe("/path/to/key");
25
+ expect(result.port).toBe(2222);
26
+ expect(result.connectTimeout).toBe(30);
27
+ expect(result.controlPersist).toBe("30m");
28
+ expect(result.serverAliveInterval).toBe(60);
29
+ });
30
+
31
+ it("parses config with only required fields (defaults applied)", () => {
32
+ const input = {
33
+ host: "example.com",
34
+ keyPath: "/home/user/.ssh/id_rsa",
35
+ };
36
+
37
+ const result = BifrostConfigSchema.parse(input);
38
+
39
+ expect(result.host).toBe("example.com");
40
+ expect(result.keyPath).toBe("/home/user/.ssh/id_rsa");
41
+ expect(result.user).toBe("root");
42
+ expect(result.port).toBe(22);
43
+ expect(result.connectTimeout).toBe(10);
44
+ expect(result.controlPersist).toBe("15m");
45
+ expect(result.serverAliveInterval).toBe(30);
46
+ });
47
+ });
48
+
49
+ describe("invalid config validation", () => {
50
+ it("throws Zod error when host is missing", () => {
51
+ const input = {
52
+ keyPath: "/path/to/key",
53
+ };
54
+
55
+ expect(() => BifrostConfigSchema.parse(input)).toThrow();
56
+ });
57
+
58
+ it("throws Zod error when keyPath is missing", () => {
59
+ const input = {
60
+ host: "example.com",
61
+ };
62
+
63
+ expect(() => BifrostConfigSchema.parse(input)).toThrow();
64
+ });
65
+
66
+ it("throws Zod error for wrong types", () => {
67
+ const input = {
68
+ host: "example.com",
69
+ keyPath: "/path/to/key",
70
+ port: "not-a-number",
71
+ };
72
+
73
+ expect(() => BifrostConfigSchema.parse(input)).toThrow();
74
+ });
75
+
76
+ it("throws Zod error for negative port", () => {
77
+ const input = {
78
+ host: "example.com",
79
+ keyPath: "/path/to/key",
80
+ port: -1,
81
+ };
82
+
83
+ expect(() => BifrostConfigSchema.parse(input)).toThrow();
84
+ });
85
+
86
+ it("throws Zod error for non-integer port", () => {
87
+ const input = {
88
+ host: "example.com",
89
+ keyPath: "/path/to/key",
90
+ port: 22.5,
91
+ };
92
+
93
+ expect(() => BifrostConfigSchema.parse(input)).toThrow();
94
+ });
95
+ });
96
+ });
97
+
98
+ describe("parseConfig", () => {
99
+ let tempDir: string;
100
+ let keyPath: string;
101
+
102
+ beforeEach(() => {
103
+ tempDir = mkdtempSync(join(tmpdir(), "bifrost-test-"));
104
+ keyPath = join(tempDir, "test_key");
105
+ writeFileSync(keyPath, "fake key content");
106
+ chmodSync(keyPath, 0o600);
107
+ });
108
+
109
+ afterEach(() => {
110
+ rmSync(tempDir, { recursive: true, force: true });
111
+ });
112
+
113
+ it("parses valid config file with all fields", () => {
114
+ const configPath = join(tempDir, "config.json");
115
+ const config = {
116
+ host: "192.168.1.100",
117
+ user: "admin",
118
+ keyPath: keyPath,
119
+ port: 2222,
120
+ connectTimeout: 30,
121
+ controlPersist: "30m",
122
+ serverAliveInterval: 60,
123
+ };
124
+ writeFileSync(configPath, JSON.stringify(config));
125
+
126
+ const result = parseConfig(configPath);
127
+
128
+ expect(result.host).toBe("192.168.1.100");
129
+ expect(result.user).toBe("admin");
130
+ expect(result.keyPath).toBe(keyPath);
131
+ expect(result.port).toBe(2222);
132
+ expect(result.connectTimeout).toBe(30);
133
+ expect(result.controlPersist).toBe("30m");
134
+ expect(result.serverAliveInterval).toBe(60);
135
+ });
136
+
137
+ it("parses valid config file with only required fields", () => {
138
+ const configPath = join(tempDir, "config.json");
139
+ const config = {
140
+ host: "example.com",
141
+ keyPath: keyPath,
142
+ };
143
+ writeFileSync(configPath, JSON.stringify(config));
144
+
145
+ const result = parseConfig(configPath);
146
+
147
+ expect(result.host).toBe("example.com");
148
+ expect(result.keyPath).toBe(keyPath);
149
+ expect(result.user).toBe("root");
150
+ expect(result.port).toBe(22);
151
+ expect(result.connectTimeout).toBe(10);
152
+ expect(result.controlPersist).toBe("15m");
153
+ expect(result.serverAliveInterval).toBe(30);
154
+ });
155
+
156
+ it("throws error for missing host", () => {
157
+ const configPath = join(tempDir, "config.json");
158
+ const config = {
159
+ keyPath: keyPath,
160
+ };
161
+ writeFileSync(configPath, JSON.stringify(config));
162
+
163
+ expect(() => parseConfig(configPath)).toThrow(/host/i);
164
+ });
165
+
166
+ it("throws error for missing keyPath", () => {
167
+ const configPath = join(tempDir, "config.json");
168
+ const config = {
169
+ host: "example.com",
170
+ };
171
+ writeFileSync(configPath, JSON.stringify(config));
172
+
173
+ expect(() => parseConfig(configPath)).toThrow(/keyPath/i);
174
+ });
175
+
176
+ it("throws error for wrong types", () => {
177
+ const configPath = join(tempDir, "config.json");
178
+ const config = {
179
+ host: "example.com",
180
+ keyPath: keyPath,
181
+ port: "not-a-number",
182
+ };
183
+ writeFileSync(configPath, JSON.stringify(config));
184
+
185
+ expect(() => parseConfig(configPath)).toThrow();
186
+ });
187
+
188
+ it("expands tilde in keyPath", () => {
189
+ const configPath = join(tempDir, "config.json");
190
+ const homeKeyPath = join(homedir(), ".ssh", "bifrost-test-key");
191
+ mkdirSync(join(homedir(), ".ssh"), { recursive: true });
192
+ writeFileSync(homeKeyPath, "fake key content");
193
+ chmodSync(homeKeyPath, 0o600);
194
+
195
+ try {
196
+ const config = {
197
+ host: "example.com",
198
+ keyPath: "~/.ssh/bifrost-test-key",
199
+ };
200
+ writeFileSync(configPath, JSON.stringify(config));
201
+
202
+ const result = parseConfig(configPath);
203
+
204
+ expect(result.keyPath).toBe(homeKeyPath);
205
+ } finally {
206
+ rmSync(homeKeyPath, { force: true });
207
+ }
208
+ });
209
+
210
+ it("throws error when config file does not exist", () => {
211
+ expect(() => parseConfig("/nonexistent/path/config.json")).toThrow(
212
+ /Config file not found/
213
+ );
214
+ });
215
+
216
+ it("throws error when key file does not exist", () => {
217
+ const configPath = join(tempDir, "config.json");
218
+ const config = {
219
+ host: "example.com",
220
+ keyPath: "/nonexistent/key",
221
+ };
222
+ writeFileSync(configPath, JSON.stringify(config));
223
+
224
+ expect(() => parseConfig(configPath)).toThrow(/Key file not found/);
225
+ });
226
+
227
+ it("throws error for invalid JSON", () => {
228
+ const configPath = join(tempDir, "config.json");
229
+ writeFileSync(configPath, "{ invalid json }");
230
+
231
+ expect(() => parseConfig(configPath)).toThrow(/JSON parse error/);
232
+ });
233
+ });
@@ -0,0 +1,199 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeAll,
6
+ afterAll,
7
+ beforeEach,
8
+ afterEach,
9
+ } from "bun:test";
10
+ import { BifrostManager, BifrostError } from "../src/manager";
11
+ import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs";
12
+ import { tmpdir, homedir } from "os";
13
+ import { join } from "path";
14
+
15
+ const INTEGRATION_ENABLED = process.env.BIFROST_INTEGRATION === "true";
16
+ const CONFIG_PATH = join(homedir(), ".config", "opencode", "bifrost.json");
17
+
18
+ const describeIntegration = INTEGRATION_ENABLED ? describe : describe.skip;
19
+
20
+ describeIntegration("Integration Tests", () => {
21
+ let manager: BifrostManager;
22
+ let tempDir: string;
23
+
24
+ beforeAll(() => {
25
+ if (!existsSync(CONFIG_PATH)) {
26
+ throw new Error(
27
+ `Integration tests require config at ${CONFIG_PATH}. Set BIFROST_INTEGRATION=true to run.`
28
+ );
29
+ }
30
+ });
31
+
32
+ beforeEach(() => {
33
+ manager = new BifrostManager();
34
+ manager.loadConfig(CONFIG_PATH);
35
+ tempDir = mkdtempSync(join(tmpdir(), "bifrost-integration-"));
36
+ });
37
+
38
+ afterEach(async () => {
39
+ try {
40
+ await manager.disconnect();
41
+ } catch {
42
+ }
43
+ rmSync(tempDir, { recursive: true, force: true });
44
+ });
45
+
46
+ describe("full lifecycle", () => {
47
+ it("connect → exec → disconnect", async () => {
48
+ expect(manager.state).toBe("disconnected");
49
+
50
+ await manager.connect();
51
+ expect(manager.state).toBe("connected");
52
+
53
+ const result = await manager.exec("echo hello");
54
+ expect(result.stdout.trim()).toBe("hello");
55
+ expect(result.exitCode).toBe(0);
56
+
57
+ await manager.disconnect();
58
+ expect(manager.state).toBe("disconnected");
59
+ });
60
+
61
+ it("sequential command execution", async () => {
62
+ await manager.connect();
63
+
64
+ const result1 = await manager.exec("echo first");
65
+ expect(result1.stdout.trim()).toBe("first");
66
+
67
+ const result2 = await manager.exec("echo second");
68
+ expect(result2.stdout.trim()).toBe("second");
69
+
70
+ const result3 = await manager.exec("echo third");
71
+ expect(result3.stdout.trim()).toBe("third");
72
+
73
+ await manager.disconnect();
74
+ });
75
+ });
76
+
77
+ describe("auto-reconnect", () => {
78
+ it("reconnects after connection loss via ensureConnected", async () => {
79
+ await manager.connect();
80
+ expect(manager.state).toBe("connected");
81
+
82
+ await manager.disconnect();
83
+ expect(manager.state).toBe("disconnected");
84
+
85
+ await manager.ensureConnected();
86
+ expect(manager.state).toBe("connected");
87
+
88
+ const result = await manager.exec("echo reconnected");
89
+ expect(result.stdout.trim()).toBe("reconnected");
90
+ });
91
+ });
92
+
93
+ describe("file transfer", () => {
94
+ it("upload file", async () => {
95
+ await manager.connect();
96
+
97
+ const localFile = join(tempDir, "upload-test.txt");
98
+ const remotePath = `/tmp/bifrost-upload-test-${Date.now()}.txt`;
99
+ const testContent = `test content ${Date.now()}`;
100
+
101
+ writeFileSync(localFile, testContent);
102
+
103
+ await manager.upload(localFile, remotePath);
104
+
105
+ const result = await manager.exec(`cat ${remotePath} && rm ${remotePath}`);
106
+ expect(result.stdout.trim()).toBe(testContent);
107
+ });
108
+
109
+ it("download file", async () => {
110
+ await manager.connect();
111
+
112
+ const remotePath = `/tmp/bifrost-download-test-${Date.now()}.txt`;
113
+ const localFile = join(tempDir, "download-test.txt");
114
+ const testContent = `download content ${Date.now()}`;
115
+
116
+ await manager.exec(`echo '${testContent}' > ${remotePath}`);
117
+
118
+ await manager.download(remotePath, localFile);
119
+
120
+ expect(existsSync(localFile)).toBe(true);
121
+ const downloaded = readFileSync(localFile, "utf-8").trim();
122
+ expect(downloaded).toBe(testContent);
123
+
124
+ await manager.exec(`rm ${remotePath}`);
125
+ });
126
+ });
127
+
128
+ describe("status checks", () => {
129
+ it("isConnected returns true when connected", async () => {
130
+ await manager.connect();
131
+ const connected = await manager.isConnected();
132
+ expect(connected).toBe(true);
133
+ });
134
+
135
+ it("isConnected returns false when disconnected", async () => {
136
+ const connected = await manager.isConnected();
137
+ expect(connected).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe("error handling", () => {
142
+ it("exec throws when not connected", async () => {
143
+ try {
144
+ await manager.exec("echo test");
145
+ expect(true).toBe(false);
146
+ } catch (err) {
147
+ expect(err).toBeInstanceOf(BifrostError);
148
+ expect((err as BifrostError).code).toBe("INVALID_STATE");
149
+ }
150
+ });
151
+ });
152
+ });
153
+
154
+ describeIntegration("Integration Tests - Error Cases", () => {
155
+ describe("unreachable host", () => {
156
+ it("throws UNREACHABLE error for invalid host", async () => {
157
+ const manager = new BifrostManager();
158
+ const tempDir = mkdtempSync(join(tmpdir(), "bifrost-unreachable-"));
159
+ const keyPath = join(tempDir, "fake_key");
160
+ const configPath = join(tempDir, "config.json");
161
+
162
+ writeFileSync(keyPath, "fake key");
163
+ const { chmodSync } = await import("fs");
164
+ chmodSync(keyPath, 0o600);
165
+
166
+ writeFileSync(
167
+ configPath,
168
+ JSON.stringify({
169
+ host: "192.0.2.1",
170
+ keyPath: keyPath,
171
+ connectTimeout: 3,
172
+ })
173
+ );
174
+
175
+ manager.loadConfig(configPath);
176
+
177
+ try {
178
+ await manager.connect();
179
+ expect(true).toBe(false);
180
+ } catch (err) {
181
+ expect(err).toBeInstanceOf(BifrostError);
182
+ const bifrostErr = err as BifrostError;
183
+ expect(["UNREACHABLE", "AUTH_FAILED"]).toContain(bifrostErr.code);
184
+ } finally {
185
+ rmSync(tempDir, { recursive: true, force: true });
186
+ }
187
+ });
188
+ });
189
+ });
190
+
191
+ describe("Integration Tests - Skip Check", () => {
192
+ it("skips when BIFROST_INTEGRATION is not set", () => {
193
+ if (!INTEGRATION_ENABLED) {
194
+ expect(true).toBe(true);
195
+ } else {
196
+ expect(INTEGRATION_ENABLED).toBe(true);
197
+ }
198
+ });
199
+ });