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,209 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { BifrostManager, BifrostError } from "../src/manager";
3
+ import { mkdtempSync, writeFileSync, rmSync, chmodSync, statSync } from "fs";
4
+ import { tmpdir, homedir } from "os";
5
+ import { join } from "path";
6
+
7
+ describe("BifrostManager", () => {
8
+ let manager: BifrostManager;
9
+ let tempDir: string;
10
+ let keyPath: string;
11
+ let configPath: string;
12
+
13
+ beforeEach(() => {
14
+ manager = new BifrostManager();
15
+ tempDir = mkdtempSync(join(tmpdir(), "bifrost-manager-test-"));
16
+ keyPath = join(tempDir, "test_key");
17
+ configPath = join(tempDir, "config.json");
18
+
19
+ writeFileSync(keyPath, "fake key content");
20
+ chmodSync(keyPath, 0o600);
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(tempDir, { recursive: true, force: true });
25
+ });
26
+
27
+ describe("initial state", () => {
28
+ it("starts in disconnected state", () => {
29
+ expect(manager.state).toBe("disconnected");
30
+ });
31
+
32
+ it("has null config initially", () => {
33
+ expect(manager.config).toBeNull();
34
+ });
35
+
36
+ it("has null controlPath initially", () => {
37
+ expect(manager.controlPath).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe("loadConfig", () => {
42
+ it("loads valid config", () => {
43
+ const config = {
44
+ host: "192.168.1.100",
45
+ user: "admin",
46
+ keyPath: keyPath,
47
+ };
48
+ writeFileSync(configPath, JSON.stringify(config));
49
+
50
+ manager.loadConfig(configPath);
51
+
52
+ expect(manager.config).not.toBeNull();
53
+ expect(manager.config!.host).toBe("192.168.1.100");
54
+ expect(manager.config!.user).toBe("admin");
55
+ });
56
+
57
+ it("sets controlPath after loading config", () => {
58
+ const config = {
59
+ host: "192.168.1.100",
60
+ keyPath: keyPath,
61
+ };
62
+ writeFileSync(configPath, JSON.stringify(config));
63
+
64
+ manager.loadConfig(configPath);
65
+
66
+ expect(manager.controlPath).not.toBeNull();
67
+ expect(manager.controlPath).toContain("%C");
68
+ });
69
+
70
+ it("throws on missing config file", () => {
71
+ expect(() => manager.loadConfig("/nonexistent/config.json")).toThrow(
72
+ /Config file not found/
73
+ );
74
+ });
75
+ });
76
+
77
+ describe("socket path generation", () => {
78
+ it("uses %C hash pattern for socket path", () => {
79
+ const config = {
80
+ host: "192.168.1.100",
81
+ keyPath: keyPath,
82
+ };
83
+ writeFileSync(configPath, JSON.stringify(config));
84
+
85
+ manager.loadConfig(configPath);
86
+
87
+ expect(manager.controlPath).toContain("%C");
88
+ });
89
+
90
+ it("socket path is in socketDir", () => {
91
+ const config = {
92
+ host: "192.168.1.100",
93
+ keyPath: keyPath,
94
+ };
95
+ writeFileSync(configPath, JSON.stringify(config));
96
+
97
+ manager.loadConfig(configPath);
98
+
99
+ expect(manager.controlPath).toContain(manager.socketDir);
100
+ });
101
+ });
102
+
103
+ describe("socketDir permissions", () => {
104
+ it("socketDir path ends with bifrost-control", () => {
105
+ expect(manager.socketDir).toContain("bifrost-control");
106
+ });
107
+
108
+ it("socketDir is under .ssh directory", () => {
109
+ expect(manager.socketDir).toContain(".ssh");
110
+ });
111
+ });
112
+
113
+ describe("isConnected", () => {
114
+ it("returns false when disconnected", async () => {
115
+ expect(await manager.isConnected()).toBe(false);
116
+ });
117
+
118
+ it("returns false when config not loaded", async () => {
119
+ expect(await manager.isConnected()).toBe(false);
120
+ });
121
+ });
122
+
123
+ describe("connect without config", () => {
124
+ it("throws INVALID_STATE error", async () => {
125
+ try {
126
+ await manager.connect();
127
+ expect(true).toBe(false);
128
+ } catch (err) {
129
+ expect(err).toBeInstanceOf(BifrostError);
130
+ expect((err as BifrostError).code).toBe("INVALID_STATE");
131
+ }
132
+ });
133
+ });
134
+
135
+ describe("exec without connection", () => {
136
+ it("throws INVALID_STATE error", async () => {
137
+ try {
138
+ await manager.exec("echo hello");
139
+ expect(true).toBe(false);
140
+ } catch (err) {
141
+ expect(err).toBeInstanceOf(BifrostError);
142
+ expect((err as BifrostError).code).toBe("INVALID_STATE");
143
+ }
144
+ });
145
+ });
146
+
147
+ describe("upload without connection", () => {
148
+ it("throws INVALID_STATE error", async () => {
149
+ try {
150
+ await manager.upload("/local/path", "/remote/path");
151
+ expect(true).toBe(false);
152
+ } catch (err) {
153
+ expect(err).toBeInstanceOf(BifrostError);
154
+ expect((err as BifrostError).code).toBe("INVALID_STATE");
155
+ }
156
+ });
157
+ });
158
+
159
+ describe("download without connection", () => {
160
+ it("throws INVALID_STATE error", async () => {
161
+ try {
162
+ await manager.download("/remote/path", "/local/path");
163
+ expect(true).toBe(false);
164
+ } catch (err) {
165
+ expect(err).toBeInstanceOf(BifrostError);
166
+ expect((err as BifrostError).code).toBe("INVALID_STATE");
167
+ }
168
+ });
169
+ });
170
+
171
+ describe("disconnect when already disconnected", () => {
172
+ it("is a no-op", async () => {
173
+ await manager.disconnect();
174
+ expect(manager.state).toBe("disconnected");
175
+ });
176
+ });
177
+ });
178
+
179
+ describe("BifrostError", () => {
180
+ it("has correct name", () => {
181
+ const error = new BifrostError("test message", "UNREACHABLE");
182
+ expect(error.name).toBe("BifrostError");
183
+ });
184
+
185
+ it("has correct message", () => {
186
+ const error = new BifrostError("test message", "UNREACHABLE");
187
+ expect(error.message).toBe("test message");
188
+ });
189
+
190
+ it("has correct code", () => {
191
+ const error = new BifrostError("test message", "AUTH_FAILED");
192
+ expect(error.code).toBe("AUTH_FAILED");
193
+ });
194
+
195
+ it("supports all error codes", () => {
196
+ const codes = [
197
+ "UNREACHABLE",
198
+ "AUTH_FAILED",
199
+ "SOCKET_DEAD",
200
+ "COMMAND_FAILED",
201
+ "INVALID_STATE",
202
+ ] as const;
203
+
204
+ for (const code of codes) {
205
+ const error = new BifrostError(`Error with ${code}`, code);
206
+ expect(error.code).toBe(code);
207
+ }
208
+ });
209
+ });
@@ -0,0 +1,245 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { validatePath, validateCommand, escapeShellArg } from "../src/security";
3
+
4
+ describe("validatePath", () => {
5
+ describe("basic injection attacks", () => {
6
+ test("rejects path traversal", () => {
7
+ const result = validatePath("../etc/passwd", "remotePath");
8
+ expect(result.valid).toBe(false);
9
+ expect(result.error).toContain("path traversal");
10
+ });
11
+
12
+ test("rejects double dot in middle", () => {
13
+ const result = validatePath("/home/../root", "remotePath");
14
+ expect(result.valid).toBe(false);
15
+ expect(result.error).toContain("path traversal");
16
+ });
17
+
18
+ test("rejects command substitution with $", () => {
19
+ const result = validatePath("/tmp/$(rm -rf /)", "cwd");
20
+ expect(result.valid).toBe(false);
21
+ });
22
+
23
+ test("rejects command substitution with backtick", () => {
24
+ const result = validatePath("/tmp/`whoami`", "cwd");
25
+ expect(result.valid).toBe(false);
26
+ });
27
+
28
+ test("rejects command chaining with semicolon", () => {
29
+ const result = validatePath("/tmp; rm -rf /", "cwd");
30
+ expect(result.valid).toBe(false);
31
+ });
32
+
33
+ test("rejects command chaining with pipe", () => {
34
+ const result = validatePath("/tmp | cat /etc/passwd", "cwd");
35
+ expect(result.valid).toBe(false);
36
+ });
37
+
38
+ test("rejects command chaining with ampersand", () => {
39
+ const result = validatePath("/tmp & whoami", "cwd");
40
+ expect(result.valid).toBe(false);
41
+ });
42
+
43
+ test("rejects flag injection", () => {
44
+ const result = validatePath("-rf /", "cwd");
45
+ expect(result.valid).toBe(false);
46
+ expect(result.error).toContain("flag injection");
47
+ });
48
+
49
+ test("rejects newline injection", () => {
50
+ const result = validatePath("/tmp\nrm -rf /", "cwd");
51
+ expect(result.valid).toBe(false);
52
+ });
53
+
54
+ test("rejects empty path", () => {
55
+ const result = validatePath("", "remotePath");
56
+ expect(result.valid).toBe(false);
57
+ expect(result.error).toContain("cannot be empty");
58
+ });
59
+ });
60
+
61
+ describe("advanced bypass attempts", () => {
62
+ test("rejects glob patterns with asterisk", () => {
63
+ const result = validatePath("/???/c?t /???/p*sswd", "cwd");
64
+ expect(result.valid).toBe(false);
65
+ });
66
+
67
+ test("rejects glob patterns with question mark", () => {
68
+ const result = validatePath("/bin/c?t", "cwd");
69
+ expect(result.valid).toBe(false);
70
+ });
71
+
72
+ test("rejects glob patterns with brackets", () => {
73
+ const result = validatePath("/bin/[c]at", "cwd");
74
+ expect(result.valid).toBe(false);
75
+ });
76
+
77
+ test("rejects brace expansion", () => {
78
+ const result = validatePath("{cat,/etc/passwd}", "cwd");
79
+ expect(result.valid).toBe(false);
80
+ });
81
+
82
+ test("rejects redirection with >", () => {
83
+ const result = validatePath("/tmp > /etc/passwd", "cwd");
84
+ expect(result.valid).toBe(false);
85
+ });
86
+
87
+ test("rejects redirection with <", () => {
88
+ const result = validatePath("cat < /etc/passwd", "cwd");
89
+ expect(result.valid).toBe(false);
90
+ });
91
+
92
+ test("rejects backslash escape", () => {
93
+ const result = validatePath("c\\at /et\\c/pas\\swd", "cwd");
94
+ expect(result.valid).toBe(false);
95
+ });
96
+
97
+ test("rejects tilde expansion", () => {
98
+ const result = validatePath("~root/.ssh/id_rsa", "cwd");
99
+ expect(result.valid).toBe(false);
100
+ });
101
+
102
+ test("rejects history expansion with !", () => {
103
+ const result = validatePath("!!", "cwd");
104
+ expect(result.valid).toBe(false);
105
+ });
106
+
107
+ test("rejects hash comment", () => {
108
+ const result = validatePath("/tmp #comment", "cwd");
109
+ expect(result.valid).toBe(false);
110
+ });
111
+
112
+ test("rejects caret substitution", () => {
113
+ const result = validatePath("^old^new", "cwd");
114
+ expect(result.valid).toBe(false);
115
+ });
116
+
117
+ test("rejects tab character", () => {
118
+ const result = validatePath("/tmp\t/etc", "cwd");
119
+ expect(result.valid).toBe(false);
120
+ expect(result.error).toContain("tab");
121
+ });
122
+
123
+ test("rejects null byte", () => {
124
+ const result = validatePath("/tmp\x00/etc", "cwd");
125
+ expect(result.valid).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe("unicode bypass attempts", () => {
130
+ test("rejects fullwidth semicolon", () => {
131
+ const result = validatePath("/tmp\uFF1Bwhoami", "cwd");
132
+ expect(result.valid).toBe(false);
133
+ expect(result.error).toContain("unicode");
134
+ });
135
+
136
+ test("rejects fullwidth pipe", () => {
137
+ const result = validatePath("/tmp\uFF5Cwhoami", "cwd");
138
+ expect(result.valid).toBe(false);
139
+ });
140
+
141
+ test("rejects smart quotes", () => {
142
+ const result = validatePath("/tmp\u201Ctest\u201D", "cwd");
143
+ expect(result.valid).toBe(false);
144
+ });
145
+
146
+ test("rejects unicode dash variants", () => {
147
+ const result = validatePath("\u2013rf /", "cwd");
148
+ expect(result.valid).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe("valid paths", () => {
153
+ test("accepts valid absolute path", () => {
154
+ const result = validatePath("/home/user/file.txt", "remotePath");
155
+ expect(result.valid).toBe(true);
156
+ });
157
+
158
+ test("accepts valid relative path without traversal", () => {
159
+ const result = validatePath("subdir/file.txt", "remotePath");
160
+ expect(result.valid).toBe(true);
161
+ });
162
+
163
+ test("accepts path with spaces", () => {
164
+ const result = validatePath("/home/user/my file.txt", "remotePath");
165
+ expect(result.valid).toBe(true);
166
+ });
167
+
168
+ test("accepts path with dashes and underscores", () => {
169
+ const result = validatePath("/home/user/my-file_name.txt", "remotePath");
170
+ expect(result.valid).toBe(true);
171
+ });
172
+
173
+ test("accepts path with dots in filename", () => {
174
+ const result = validatePath("/home/user/file.tar.gz", "remotePath");
175
+ expect(result.valid).toBe(true);
176
+ });
177
+
178
+ test("accepts path with @ symbol", () => {
179
+ const result = validatePath("/home/user@host/file", "remotePath");
180
+ expect(result.valid).toBe(true);
181
+ });
182
+
183
+ test("accepts path with colon", () => {
184
+ const result = validatePath("/home/user/file:2", "remotePath");
185
+ expect(result.valid).toBe(true);
186
+ });
187
+ });
188
+
189
+ describe("length limits", () => {
190
+ test("rejects path exceeding 4096 chars", () => {
191
+ const longPath = "/home/" + "a".repeat(4100);
192
+ const result = validatePath(longPath, "remotePath");
193
+ expect(result.valid).toBe(false);
194
+ expect(result.error).toContain("maximum length");
195
+ });
196
+ });
197
+ });
198
+
199
+ describe("validateCommand", () => {
200
+ test("accepts valid command", () => {
201
+ const result = validateCommand("ls -la /home");
202
+ expect(result.valid).toBe(true);
203
+ });
204
+
205
+ test("rejects empty command", () => {
206
+ const result = validateCommand("");
207
+ expect(result.valid).toBe(false);
208
+ });
209
+
210
+ test("rejects null byte in command", () => {
211
+ const result = validateCommand("ls\x00 -la");
212
+ expect(result.valid).toBe(false);
213
+ expect(result.error).toContain("null byte");
214
+ });
215
+
216
+ test("rejects unicode bypass in command", () => {
217
+ const result = validateCommand("ls\uFF1B whoami");
218
+ expect(result.valid).toBe(false);
219
+ expect(result.error).toContain("unicode");
220
+ });
221
+
222
+ test("rejects command exceeding 64KB", () => {
223
+ const longCmd = "echo " + "a".repeat(70000);
224
+ const result = validateCommand(longCmd);
225
+ expect(result.valid).toBe(false);
226
+ expect(result.error).toContain("maximum length");
227
+ });
228
+ });
229
+
230
+ describe("escapeShellArg", () => {
231
+ test("escapes single quotes", () => {
232
+ const result = escapeShellArg("it's a test");
233
+ expect(result).toBe("it'\\''s a test");
234
+ });
235
+
236
+ test("handles multiple single quotes", () => {
237
+ const result = escapeShellArg("it's Bob's file");
238
+ expect(result).toBe("it'\\''s Bob'\\''s file");
239
+ });
240
+
241
+ test("leaves string without quotes unchanged", () => {
242
+ const result = escapeShellArg("simple-path");
243
+ expect(result).toBe("simple-path");
244
+ });
245
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ESNext"],
7
+ "types": ["bun-types"],
8
+
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "noImplicitOverride": true,
12
+ "exactOptionalPropertyTypes": true,
13
+
14
+ "esModuleInterop": true,
15
+ "skipLibCheck": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "outDir": "dist",
21
+ "rootDir": "src",
22
+ "allowJs": true,
23
+ "resolveJsonModule": true
24
+ },
25
+ "include": ["src/**/*"],
26
+ "exclude": ["node_modules", "dist", "test"]
27
+ }