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
package/src/manager.ts ADDED
@@ -0,0 +1,471 @@
1
+ import { homedir } from "os";
2
+ import { readdirSync, unlinkSync } from "fs";
3
+ import type { BifrostConfig } from "./config";
4
+ import { parseConfig } from "./config";
5
+
6
+ export type ConnectionState =
7
+ | "disconnected"
8
+ | "connecting"
9
+ | "connected"
10
+ | "disconnecting";
11
+
12
+ export type BifrostErrorCode =
13
+ | "UNREACHABLE"
14
+ | "AUTH_FAILED"
15
+ | "SOCKET_DEAD"
16
+ | "COMMAND_FAILED"
17
+ | "INVALID_STATE"
18
+ | "TIMEOUT";
19
+
20
+ export class BifrostError extends Error {
21
+ public override readonly name = "BifrostError" as const;
22
+
23
+ constructor(
24
+ message: string,
25
+ public readonly code: BifrostErrorCode
26
+ ) {
27
+ super(message);
28
+ }
29
+ }
30
+
31
+ export interface ExecResult {
32
+ stdout: string;
33
+ stderr: string;
34
+ exitCode: number;
35
+ }
36
+
37
+ export interface ExecOptions {
38
+ timeout?: number;
39
+ maxOutputBytes?: number;
40
+ }
41
+
42
+ const SOCKET_DIR = `${homedir()}/.ssh/bifrost-control`;
43
+ const DEFAULT_TIMEOUT = 30_000;
44
+ const DEFAULT_MAX_OUTPUT = 10 * 1024 * 1024;
45
+
46
+ function withTimeout<T>(promise: Promise<T>, ms: number, operation: string): Promise<T> {
47
+ return Promise.race([
48
+ promise,
49
+ new Promise<T>((_, reject) =>
50
+ setTimeout(() => reject(new BifrostError(`${operation} timed out after ${ms}ms`, "TIMEOUT")), ms)
51
+ )
52
+ ]);
53
+ }
54
+
55
+ async function readStreamLimited(stream: ReadableStream<Uint8Array>, maxBytes: number): Promise<string> {
56
+ const reader = stream.getReader();
57
+ const chunks: Uint8Array[] = [];
58
+ let totalBytes = 0;
59
+ let truncated = false;
60
+
61
+ try {
62
+ while (true) {
63
+ const { done, value } = await reader.read();
64
+ if (done) break;
65
+
66
+ if (totalBytes + value.length > maxBytes) {
67
+ const remaining = maxBytes - totalBytes;
68
+ if (remaining > 0) {
69
+ chunks.push(value.slice(0, remaining));
70
+ }
71
+ truncated = true;
72
+ break;
73
+ }
74
+
75
+ chunks.push(value);
76
+ totalBytes += value.length;
77
+ }
78
+ } finally {
79
+ reader.releaseLock();
80
+ }
81
+
82
+ const combined = new Uint8Array(Math.min(totalBytes, maxBytes));
83
+ let offset = 0;
84
+ for (const chunk of chunks) {
85
+ combined.set(chunk, offset);
86
+ offset += chunk.length;
87
+ }
88
+
89
+ const text = new TextDecoder().decode(combined);
90
+ return truncated ? text + `\n... [truncated at ${maxBytes} bytes]` : text;
91
+ }
92
+
93
+ export class BifrostManager implements AsyncDisposable {
94
+ private _state: ConnectionState = "disconnected";
95
+ private _config: BifrostConfig | null = null;
96
+ private _controlPath: string | null = null;
97
+ private _mutex: Promise<void> = Promise.resolve();
98
+
99
+ /**
100
+ * Explicit Resource Management (ES2024)
101
+ * Enables: `await using manager = new BifrostManager()`
102
+ * Auto-disconnects when scope exits
103
+ */
104
+ async [Symbol.asyncDispose](): Promise<void> {
105
+ await this.disconnect();
106
+ }
107
+
108
+ get state(): ConnectionState {
109
+ return this._state;
110
+ }
111
+
112
+ get config(): BifrostConfig | null {
113
+ return this._config;
114
+ }
115
+
116
+ get controlPath(): string | null {
117
+ return this._controlPath;
118
+ }
119
+
120
+ get socketDir(): string {
121
+ return SOCKET_DIR;
122
+ }
123
+
124
+ private async withMutex<T>(fn: () => Promise<T>): Promise<T> {
125
+ const prev = this._mutex;
126
+ let resolve: () => void;
127
+ this._mutex = new Promise<void>(r => { resolve = r; });
128
+
129
+ try {
130
+ await prev;
131
+ return await fn();
132
+ } finally {
133
+ resolve!();
134
+ }
135
+ }
136
+
137
+ loadConfig(configPath: string): void {
138
+ this._config = parseConfig(configPath);
139
+ this._controlPath = `${SOCKET_DIR}/%C`;
140
+ }
141
+
142
+ private async ensureSocketDir(): Promise<void> {
143
+ const result = Bun.spawnSync(["mkdir", "-p", SOCKET_DIR]);
144
+ if (result.exitCode !== 0) {
145
+ throw new BifrostError(
146
+ `Failed to create socket directory: ${result.stderr.toString()}`,
147
+ "COMMAND_FAILED"
148
+ );
149
+ }
150
+
151
+ const chmodResult = Bun.spawnSync(["chmod", "700", SOCKET_DIR]);
152
+ if (chmodResult.exitCode !== 0) {
153
+ throw new BifrostError(
154
+ `Failed to set socket directory permissions: ${chmodResult.stderr.toString()}`,
155
+ "COMMAND_FAILED"
156
+ );
157
+ }
158
+ }
159
+
160
+ private translateSSHError(exitCode: number, stderr: string): BifrostError {
161
+ const stderrLower = stderr.toLowerCase();
162
+
163
+ if (exitCode === 255) {
164
+ if (stderrLower.includes("permission denied") ||
165
+ stderrLower.includes("authentication failed") ||
166
+ stderrLower.includes("publickey")) {
167
+ return new BifrostError(
168
+ `Authentication failed. Check key at ${this._config?.keyPath}`,
169
+ "AUTH_FAILED"
170
+ );
171
+ }
172
+ if (stderrLower.includes("connection refused") ||
173
+ stderrLower.includes("no route to host") ||
174
+ stderrLower.includes("connection timed out") ||
175
+ stderrLower.includes("could not resolve")) {
176
+ return new BifrostError(
177
+ `Server unreachable at ${this._config?.host}:${this._config?.port}`,
178
+ "UNREACHABLE"
179
+ );
180
+ }
181
+ return new BifrostError(
182
+ `SSH connection error: ${stderr}`,
183
+ "UNREACHABLE"
184
+ );
185
+ }
186
+
187
+ return new BifrostError(
188
+ `Command failed with exit code ${exitCode}: ${stderr}`,
189
+ "COMMAND_FAILED"
190
+ );
191
+ }
192
+
193
+ private getDestination(): string {
194
+ if (!this._config) {
195
+ throw new BifrostError("No config loaded", "INVALID_STATE");
196
+ }
197
+ return `${this._config.user}@${this._config.host}`;
198
+ }
199
+
200
+ async connect(): Promise<void> {
201
+ return this.withMutex(async () => {
202
+ if (this._state === "connected") {
203
+ return;
204
+ }
205
+
206
+ if (this._state === "connecting" || this._state === "disconnecting") {
207
+ throw new BifrostError(`Cannot connect in state: ${this._state}`, "INVALID_STATE");
208
+ }
209
+
210
+ if (!this._config) {
211
+ throw new BifrostError("No config loaded. Call loadConfig() first", "INVALID_STATE");
212
+ }
213
+
214
+ this._state = "connecting";
215
+
216
+ try {
217
+ this.cleanup();
218
+ await this.ensureSocketDir();
219
+ await this.doConnect();
220
+ this._state = "connected";
221
+ } catch (error) {
222
+ this._state = "disconnected";
223
+ throw error;
224
+ }
225
+ });
226
+ }
227
+
228
+ async disconnect(): Promise<void> {
229
+ return this.withMutex(async () => {
230
+ if (this._state === "disconnected") {
231
+ return;
232
+ }
233
+
234
+ if (this._state === "disconnecting" || this._state === "connecting") {
235
+ throw new BifrostError(`Cannot disconnect in state: ${this._state}`, "INVALID_STATE");
236
+ }
237
+
238
+ if (!this._config || !this._controlPath) {
239
+ this._state = "disconnected";
240
+ return;
241
+ }
242
+
243
+ this._state = "disconnecting";
244
+
245
+ try {
246
+ const args = [
247
+ "ssh",
248
+ "-O", "exit",
249
+ "-o", `ControlPath=${this._controlPath}`,
250
+ this.getDestination(),
251
+ ];
252
+
253
+ const proc = Bun.spawn(args, {
254
+ stdout: "pipe",
255
+ stderr: "pipe",
256
+ });
257
+
258
+ await withTimeout(proc.exited, 5000, "SSH disconnect");
259
+ } catch {
260
+ } finally {
261
+ this._state = "disconnected";
262
+ }
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Health check via ssh -O check
268
+ * Returns true if connection is alive
269
+ */
270
+ async isConnected(): Promise<boolean> {
271
+ if (this._state !== "connected" || !this._config || !this._controlPath) {
272
+ return false;
273
+ }
274
+
275
+ const args = [
276
+ "ssh",
277
+ "-O", "check",
278
+ "-o", `ControlPath=${this._controlPath}`,
279
+ this.getDestination(),
280
+ ];
281
+
282
+ const proc = Bun.spawn(args, {
283
+ stdout: "pipe",
284
+ stderr: "pipe",
285
+ });
286
+
287
+ const exitCode = await proc.exited;
288
+ return exitCode === 0;
289
+ }
290
+
291
+ async exec(command: string, options: ExecOptions = {}): Promise<ExecResult> {
292
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
293
+ const maxOutput = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
294
+
295
+ if (this._state !== "connected") {
296
+ throw new BifrostError(
297
+ `Cannot exec in state: ${this._state}. Call connect() first`,
298
+ "INVALID_STATE"
299
+ );
300
+ }
301
+
302
+ if (!this._config || !this._controlPath) {
303
+ throw new BifrostError("No config or control path", "INVALID_STATE");
304
+ }
305
+
306
+ const alive = await this.isConnected();
307
+ if (!alive) {
308
+ this._state = "disconnected";
309
+ throw new BifrostError(
310
+ "Connection dead. Socket exists but connection failed",
311
+ "SOCKET_DEAD"
312
+ );
313
+ }
314
+
315
+ const escapedCommand = command.replace(/'/g, "'\\''");
316
+
317
+ const args = [
318
+ "ssh",
319
+ "-o", `ControlPath=${this._controlPath}`,
320
+ this.getDestination(),
321
+ `bash -l -c '${escapedCommand}'`,
322
+ ];
323
+
324
+ const proc = Bun.spawn(args, {
325
+ stdout: "pipe",
326
+ stderr: "pipe",
327
+ });
328
+
329
+ try {
330
+ const exitCode = await withTimeout(proc.exited, timeout, "Command execution");
331
+ const [stdout, stderr] = await Promise.all([
332
+ readStreamLimited(proc.stdout, maxOutput),
333
+ readStreamLimited(proc.stderr, maxOutput),
334
+ ]);
335
+
336
+ return { stdout, stderr, exitCode };
337
+ } catch (error) {
338
+ proc.kill();
339
+ throw error;
340
+ }
341
+ }
342
+
343
+ private async runSftp(sftpCommand: string, timeout: number = 60000): Promise<void> {
344
+ if (this._state !== "connected") {
345
+ throw new BifrostError(
346
+ `Cannot run SFTP in state: ${this._state}. Call connect() first`,
347
+ "INVALID_STATE"
348
+ );
349
+ }
350
+
351
+ if (!this._config || !this._controlPath) {
352
+ throw new BifrostError("No config or control path", "INVALID_STATE");
353
+ }
354
+
355
+ const args = [
356
+ "sftp",
357
+ "-o", `ControlPath=${this._controlPath}`,
358
+ "-b", "-",
359
+ this.getDestination(),
360
+ ];
361
+
362
+ const proc = Bun.spawn(args, {
363
+ stdin: new Response(sftpCommand).body,
364
+ stdout: "pipe",
365
+ stderr: "pipe",
366
+ });
367
+
368
+ try {
369
+ const exitCode = await withTimeout(proc.exited, timeout, "SFTP operation");
370
+ const stderr = await new Response(proc.stderr).text();
371
+
372
+ if (exitCode !== 0) {
373
+ throw this.translateSSHError(exitCode, stderr);
374
+ }
375
+ } catch (error) {
376
+ proc.kill();
377
+ throw error;
378
+ }
379
+ }
380
+
381
+ async upload(localPath: string, remotePath: string): Promise<void> {
382
+ await this.runSftp(`put -r "${localPath}" "${remotePath}"`);
383
+ }
384
+
385
+ async download(remotePath: string, localPath: string): Promise<void> {
386
+ await this.runSftp(`get -r "${remotePath}" "${localPath}"`);
387
+ }
388
+
389
+ async ensureConnected(): Promise<void> {
390
+ return this.withMutex(async () => {
391
+ if (this._state === "disconnected") {
392
+ this._state = "connecting";
393
+ try {
394
+ this.cleanup();
395
+ await this.ensureSocketDir();
396
+ await this.doConnect();
397
+ this._state = "connected";
398
+ } catch (error) {
399
+ this._state = "disconnected";
400
+ throw error;
401
+ }
402
+ return;
403
+ }
404
+
405
+ if (this._state === "connecting" || this._state === "disconnecting") {
406
+ throw new BifrostError(
407
+ `Cannot ensure connection in state: ${this._state}`,
408
+ "INVALID_STATE"
409
+ );
410
+ }
411
+
412
+ const alive = await this.isConnected();
413
+ if (!alive) {
414
+ this._state = "connecting";
415
+ try {
416
+ await this.doConnect();
417
+ this._state = "connected";
418
+ } catch (error) {
419
+ this._state = "disconnected";
420
+ throw error;
421
+ }
422
+ }
423
+ });
424
+ }
425
+
426
+ private async doConnect(): Promise<void> {
427
+ if (!this._config) {
428
+ throw new BifrostError("No config loaded", "INVALID_STATE");
429
+ }
430
+
431
+ const args = [
432
+ "ssh",
433
+ "-fN",
434
+ "-o", "ControlMaster=auto",
435
+ "-o", `ControlPath=${this._controlPath}`,
436
+ "-o", `ControlPersist=${this._config.controlPersist}`,
437
+ "-o", `ServerAliveInterval=${this._config.serverAliveInterval}`,
438
+ "-o", `ConnectTimeout=${this._config.connectTimeout}`,
439
+ "-o", "StrictHostKeyChecking=accept-new",
440
+ "-i", this._config.keyPath,
441
+ "-p", String(this._config.port),
442
+ this.getDestination(),
443
+ ];
444
+
445
+ const proc = Bun.spawn(args, {
446
+ stdout: "pipe",
447
+ stderr: "pipe",
448
+ });
449
+
450
+ const timeoutMs = (this._config.connectTimeout + 5) * 1000;
451
+ const exitCode = await withTimeout(proc.exited, timeoutMs, "SSH connect");
452
+ const stderr = await new Response(proc.stderr).text();
453
+
454
+ if (exitCode !== 0) {
455
+ throw this.translateSSHError(exitCode, stderr);
456
+ }
457
+ }
458
+
459
+ cleanup(): void {
460
+ try {
461
+ const files = readdirSync(SOCKET_DIR);
462
+ for (const file of files) {
463
+ try {
464
+ unlinkSync(`${SOCKET_DIR}/${file}`);
465
+ } catch {}
466
+ }
467
+ } catch {}
468
+ }
469
+ }
470
+
471
+ export const bifrostManager = new BifrostManager();
@@ -0,0 +1,98 @@
1
+ export interface ValidationResult {
2
+ valid: boolean;
3
+ error?: string;
4
+ }
5
+
6
+ function normalizeUnicode(input: string): string {
7
+ return input
8
+ .normalize("NFKC")
9
+ .replace(/[\uFF01-\uFF5E]/g, (c) =>
10
+ String.fromCharCode(c.charCodeAt(0) - 0xFEE0)
11
+ )
12
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
13
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
14
+ .replace(/[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/g, "-");
15
+ }
16
+
17
+ const SAFE_PATH_CHARS = /^[a-zA-Z0-9_.\-\/\s@:]+$/;
18
+
19
+ const DANGEROUS_PATTERNS: Array<{ pattern: RegExp; name: string }> = [
20
+ { pattern: /\.\./, name: "path traversal (..)" },
21
+ { pattern: /^\s*-/, name: "flag injection (starts with -)" },
22
+ { pattern: /[`$]/, name: "command substitution ($ or `)" },
23
+ { pattern: /[;&|]/, name: "command chaining (; & |)" },
24
+ { pattern: /[\n\r]/, name: "newline injection" },
25
+ { pattern: new RegExp("\\x00"), name: "null byte" },
26
+ { pattern: /[\t\v\f]/, name: "tab or special whitespace" },
27
+ { pattern: /[<>]/, name: "redirection (< or >)" },
28
+ { pattern: /[*?[\]]/, name: "glob pattern (* ? [ ])" },
29
+ { pattern: /[{}]/, name: "brace expansion ({ })" },
30
+ { pattern: /[\\]/, name: "backslash escape" },
31
+ { pattern: /[!#~^]/, name: "shell expansion (! # ~ ^)" },
32
+ { pattern: /\$\(/, name: "command substitution $()" },
33
+ ];
34
+
35
+ export function validatePath(path: string, fieldName: string): ValidationResult {
36
+ if (!path || path.trim().length === 0) {
37
+ return { valid: false, error: `${fieldName} cannot be empty` };
38
+ }
39
+
40
+ const normalized = normalizeUnicode(path);
41
+
42
+ if (normalized !== path) {
43
+ return {
44
+ valid: false,
45
+ error: `${fieldName} contains suspicious unicode characters`
46
+ };
47
+ }
48
+
49
+ if (!SAFE_PATH_CHARS.test(path)) {
50
+ return {
51
+ valid: false,
52
+ error: `${fieldName} contains characters outside allowed set [a-zA-Z0-9_.\\-/@: ]`
53
+ };
54
+ }
55
+
56
+ for (const { pattern, name } of DANGEROUS_PATTERNS) {
57
+ if (pattern.test(path)) {
58
+ return {
59
+ valid: false,
60
+ error: `${fieldName} contains forbidden pattern: ${name}`,
61
+ };
62
+ }
63
+ }
64
+
65
+ if (path.length > 4096) {
66
+ return { valid: false, error: `${fieldName} exceeds maximum length (4096)` };
67
+ }
68
+
69
+ return { valid: true };
70
+ }
71
+
72
+ export function validateCommand(command: string): ValidationResult {
73
+ if (!command || command.trim().length === 0) {
74
+ return { valid: false, error: "command cannot be empty" };
75
+ }
76
+
77
+ const normalized = normalizeUnicode(command);
78
+ if (normalized !== command) {
79
+ return {
80
+ valid: false,
81
+ error: "command contains suspicious unicode characters"
82
+ };
83
+ }
84
+
85
+ if (command.includes("\x00")) {
86
+ return { valid: false, error: "command contains null byte" };
87
+ }
88
+
89
+ if (command.length > 65536) {
90
+ return { valid: false, error: "command exceeds maximum length (64KB)" };
91
+ }
92
+
93
+ return { valid: true };
94
+ }
95
+
96
+ export function escapeShellArg(arg: string): string {
97
+ return arg.replace(/'/g, "'\\''");
98
+ }
@@ -0,0 +1,35 @@
1
+ import { homedir } from "os";
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import { bifrostManager } from "../manager";
4
+
5
+ export const bifrost_connect: ToolDefinition = tool({
6
+ description:
7
+ "Establish a persistent SSH connection to the configured remote server. Uses SSH ControlMaster multiplexing. Once connected, use bifrost_exec to run commands.",
8
+ args: {
9
+ configPath: tool.schema
10
+ .string()
11
+ .optional()
12
+ .describe("Path to bifrost config file (defaults to ~/.config/opencode/bifrost.json)"),
13
+ },
14
+ execute: async (args) => {
15
+ try {
16
+ const configPath = args.configPath || `${homedir()}/.config/opencode/bifrost.json`;
17
+ bifrostManager.loadConfig(configPath);
18
+
19
+ if (bifrostManager.state === "connected") {
20
+ const config = bifrostManager.config;
21
+ return `Already connected to ${config?.user}@${config?.host}`;
22
+ }
23
+
24
+ await bifrostManager.connect();
25
+
26
+ const config = bifrostManager.config;
27
+ return `🌈 Bifrost bridge established to ${config?.user}@${config?.host}:${config?.port}\nConnection persistent. Use bifrost_exec to run commands.`;
28
+ } catch (error) {
29
+ if (error instanceof Error) {
30
+ return `Error: ${error.message}`;
31
+ }
32
+ return "Error: Unknown error occurred while connecting";
33
+ }
34
+ },
35
+ });
@@ -0,0 +1,30 @@
1
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ import { bifrostManager } from "../manager";
3
+
4
+ export const bifrost_disconnect: ToolDefinition = tool({
5
+ description:
6
+ "Disconnect the Bifrost SSH connection and clean up the control socket.",
7
+ args: {},
8
+ execute: async () => {
9
+ try {
10
+ // Check if connected
11
+ if (bifrostManager.state === "disconnected") {
12
+ return "Not connected. Nothing to disconnect.";
13
+ }
14
+
15
+ // Disconnect
16
+ await bifrostManager.disconnect();
17
+
18
+ // Get user and host for message
19
+ const config = bifrostManager.config;
20
+ const userHost = config ? `${config.user}@${config.host}` : "remote server";
21
+
22
+ return `🌈 Bifrost bridge closed. Connection to ${userHost} terminated.`;
23
+ } catch (error) {
24
+ if (error instanceof Error) {
25
+ return `Error: ${error.message}`;
26
+ }
27
+ return "Error: Unknown error occurred during disconnect";
28
+ }
29
+ },
30
+ });
@@ -0,0 +1,51 @@
1
+ import { 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_download: ToolDefinition = tool({
7
+ description:
8
+ "Download a file from the remote server to the local machine via the persistent Bifrost connection.",
9
+ args: {
10
+ remotePath: tool.schema
11
+ .string()
12
+ .describe("Remote file path to download"),
13
+ localPath: tool.schema
14
+ .string()
15
+ .describe("Local file path destination"),
16
+ },
17
+ execute: async (args) => {
18
+ try {
19
+ const remoteValidation = validatePath(args.remotePath, "remotePath");
20
+ if (!remoteValidation.valid) {
21
+ return `Error: ${remoteValidation.error}`;
22
+ }
23
+
24
+ const localValidation = validatePath(args.localPath, "localPath");
25
+ if (!localValidation.valid) {
26
+ return `Error: ${localValidation.error}`;
27
+ }
28
+
29
+ await bifrostManager.ensureConnected();
30
+
31
+ await bifrostManager.download(args.remotePath, args.localPath);
32
+
33
+ // Get file size after download
34
+ const stat = statSync(args.localPath);
35
+ const fileSize = stat.size;
36
+
37
+ // Get config for display
38
+ const config = bifrostManager.config;
39
+ if (!config) {
40
+ return "Error: No config loaded";
41
+ }
42
+
43
+ return `📥 Downloaded ${config.user}@${config.host}:${args.remotePath} → ${args.localPath} (${fileSize} bytes)`;
44
+ } catch (error) {
45
+ if (error instanceof Error) {
46
+ return `Error: ${error.message}`;
47
+ }
48
+ return "Error: Unknown error occurred during download";
49
+ }
50
+ },
51
+ });