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,54 @@
1
+ import type { BifrostConfig } from "./config";
2
+ export type ConnectionState = "disconnected" | "connecting" | "connected" | "disconnecting";
3
+ export type BifrostErrorCode = "UNREACHABLE" | "AUTH_FAILED" | "SOCKET_DEAD" | "COMMAND_FAILED" | "INVALID_STATE" | "TIMEOUT";
4
+ export declare class BifrostError extends Error {
5
+ readonly code: BifrostErrorCode;
6
+ readonly name: "BifrostError";
7
+ constructor(message: string, code: BifrostErrorCode);
8
+ }
9
+ export interface ExecResult {
10
+ stdout: string;
11
+ stderr: string;
12
+ exitCode: number;
13
+ }
14
+ export interface ExecOptions {
15
+ timeout?: number;
16
+ maxOutputBytes?: number;
17
+ }
18
+ export declare class BifrostManager implements AsyncDisposable {
19
+ private _state;
20
+ private _config;
21
+ private _controlPath;
22
+ private _mutex;
23
+ /**
24
+ * Explicit Resource Management (ES2024)
25
+ * Enables: `await using manager = new BifrostManager()`
26
+ * Auto-disconnects when scope exits
27
+ */
28
+ [Symbol.asyncDispose](): Promise<void>;
29
+ get state(): ConnectionState;
30
+ get config(): BifrostConfig | null;
31
+ get controlPath(): string | null;
32
+ get socketDir(): string;
33
+ private withMutex;
34
+ loadConfig(configPath: string): void;
35
+ private ensureSocketDir;
36
+ private translateSSHError;
37
+ private getDestination;
38
+ connect(): Promise<void>;
39
+ disconnect(): Promise<void>;
40
+ /**
41
+ * Health check via ssh -O check
42
+ * Returns true if connection is alive
43
+ */
44
+ isConnected(): Promise<boolean>;
45
+ exec(command: string, options?: ExecOptions): Promise<ExecResult>;
46
+ private runSftp;
47
+ upload(localPath: string, remotePath: string): Promise<void>;
48
+ download(remotePath: string, localPath: string): Promise<void>;
49
+ ensureConnected(): Promise<void>;
50
+ private doConnect;
51
+ cleanup(): void;
52
+ }
53
+ export declare const bifrostManager: BifrostManager;
54
+ //# sourceMappingURL=manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../src/manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAG9C,MAAM,MAAM,eAAe,GACvB,cAAc,GACd,YAAY,GACZ,WAAW,GACX,eAAe,CAAC;AAEpB,MAAM,MAAM,gBAAgB,GACxB,aAAa,GACb,aAAa,GACb,aAAa,GACb,gBAAgB,GAChB,eAAe,GACf,SAAS,CAAC;AAEd,qBAAa,YAAa,SAAQ,KAAK;aAKnB,IAAI,EAAE,gBAAgB;IAJxC,SAAyB,IAAI,EAAG,cAAc,CAAU;gBAGtD,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,gBAAgB;CAIzC;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAqDD,qBAAa,cAAe,YAAW,eAAe;IACpD,OAAO,CAAC,MAAM,CAAmC;IACjD,OAAO,CAAC,OAAO,CAA8B;IAC7C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,MAAM,CAAoC;IAElD;;;;OAIG;IACG,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5C,IAAI,KAAK,IAAI,eAAe,CAE3B;IAED,IAAI,MAAM,IAAI,aAAa,GAAG,IAAI,CAEjC;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,IAAI,CAE/B;IAED,IAAI,SAAS,IAAI,MAAM,CAEtB;YAEa,SAAS;IAavB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;YAKtB,eAAe;IAkB7B,OAAO,CAAC,iBAAiB;IAiCzB,OAAO,CAAC,cAAc;IAOhB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA4BxB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAsCjC;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IAqB/B,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;YAoD7D,OAAO;IAsCf,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI5D,QAAQ,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9D,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;YAqCxB,SAAS;IAiCvB,OAAO,IAAI,IAAI;CAUhB;AAED,eAAO,MAAM,cAAc,gBAAuB,CAAC"}
@@ -0,0 +1,8 @@
1
+ export interface ValidationResult {
2
+ valid: boolean;
3
+ error?: string;
4
+ }
5
+ export declare function validatePath(path: string, fieldName: string): ValidationResult;
6
+ export declare function validateCommand(command: string): ValidationResult;
7
+ export declare function escapeShellArg(arg: string): string;
8
+ //# sourceMappingURL=security.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../src/security.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA+BD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,gBAAgB,CAmC9E;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,CAsBjE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD"}
@@ -0,0 +1,3 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ export declare const bifrost_connect: ToolDefinition;
3
+ //# sourceMappingURL=connect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../../src/tools/connect.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,KAAK,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAGrE,eAAO,MAAM,eAAe,EAAE,cA8B5B,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ export declare const bifrost_disconnect: ToolDefinition;
3
+ //# sourceMappingURL=disconnect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"disconnect.d.ts","sourceRoot":"","sources":["../../src/tools/disconnect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAGrE,eAAO,MAAM,kBAAkB,EAAE,cA0B/B,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ export declare const bifrost_download: ToolDefinition;
3
+ //# sourceMappingURL=download.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download.d.ts","sourceRoot":"","sources":["../../src/tools/download.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,KAAK,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAIrE,eAAO,MAAM,gBAAgB,EAAE,cA6C7B,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ export declare const bifrost_exec: ToolDefinition;
3
+ //# sourceMappingURL=exec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../../src/tools/exec.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,KAAK,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAIrE,eAAO,MAAM,YAAY,EAAE,cA8DzB,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ export declare const bifrost_status: ToolDefinition;
3
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/tools/status.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,KAAK,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAIrE,eAAO,MAAM,cAAc,EAAE,cA2C3B,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ export declare const bifrost_upload: ToolDefinition;
3
+ //# sourceMappingURL=upload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/tools/upload.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,KAAK,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAIrE,eAAO,MAAM,cAAc,EAAE,cAkD3B,CAAC"}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "opencode-bifrost",
3
+ "version": "0.0.1",
4
+ "description": "OpenCode plugin for persistent SSH connections via ControlMaster multiplexing",
5
+ "author": "itsmylife44",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/itsmylife44/bifrost.git"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "build": "bun build src/index.ts --outdir dist --format esm --target node && tsc --emitDeclarationOnly",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test"
27
+ },
28
+ "dependencies": {
29
+ "@opencode-ai/plugin": "^1.1.53",
30
+ "zod": "^4.3.6"
31
+ },
32
+ "devDependencies": {
33
+ "bun-types": "latest",
34
+ "typescript": "^5.9.3"
35
+ },
36
+ "keywords": [
37
+ "opencode",
38
+ "opencode-plugin",
39
+ "plugin",
40
+ "ssh",
41
+ "controlmaster",
42
+ "ocx"
43
+ ],
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ }
47
+ }
package/src/config.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { z } from "zod";
2
+ import { readFileSync, existsSync, statSync } from "fs";
3
+ import { homedir } from "os";
4
+
5
+ const SAFE_HOST_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9.\-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/;
6
+ const SAFE_USER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_\-]*$/;
7
+
8
+ export const BifrostConfigSchema = z.object({
9
+ host: z
10
+ .string()
11
+ .min(1)
12
+ .max(253)
13
+ .refine(
14
+ (h) => SAFE_HOST_PATTERN.test(h),
15
+ { message: "Invalid host format. Use IP or hostname without special characters." }
16
+ )
17
+ .describe("IP address or hostname of the remote server"),
18
+ user: z
19
+ .string()
20
+ .default("root")
21
+ .refine(
22
+ (u) => SAFE_USER_PATTERN.test(u),
23
+ { message: "Invalid username format" }
24
+ )
25
+ .describe("SSH username for authentication"),
26
+ keyPath: z
27
+ .string()
28
+ .describe("Path to SSH private key (supports ~ expansion)"),
29
+ port: z
30
+ .number()
31
+ .int()
32
+ .positive()
33
+ .default(22)
34
+ .describe("SSH port number"),
35
+ connectTimeout: z
36
+ .number()
37
+ .int()
38
+ .positive()
39
+ .default(10)
40
+ .describe("Connection timeout in seconds"),
41
+ controlPersist: z
42
+ .string()
43
+ .default("15m")
44
+ .describe("ControlPersist value for SSH multiplexing"),
45
+ serverAliveInterval: z
46
+ .number()
47
+ .int()
48
+ .positive()
49
+ .default(30)
50
+ .describe("Keepalive interval in seconds"),
51
+ });
52
+
53
+ export type BifrostConfig = z.infer<typeof BifrostConfigSchema>;
54
+
55
+ function expandTildePath(path: string): string {
56
+ if (path.startsWith("~")) {
57
+ return path.replace("~", homedir());
58
+ }
59
+ return path;
60
+ }
61
+
62
+ function validateKeyFile(keyPath: string): void {
63
+ const expandedPath = expandTildePath(keyPath);
64
+
65
+ if (!existsSync(expandedPath)) {
66
+ throw new Error(`Key file not found: ${expandedPath}`);
67
+ }
68
+
69
+ try {
70
+ const stats = statSync(expandedPath);
71
+ const mode = stats.mode;
72
+
73
+ // Check if world-readable (0o004)
74
+ if (mode & 0o004) {
75
+ throw new Error(
76
+ `Key file ${expandedPath} is world-readable. Fix with: chmod 600 ${expandedPath}`
77
+ );
78
+ }
79
+
80
+ // Check if group-readable (0o040)
81
+ if (mode & 0o040) {
82
+ throw new Error(
83
+ `Key file ${expandedPath} is group-readable. Fix with: chmod 600 ${expandedPath}`
84
+ );
85
+ }
86
+
87
+ // Must be a regular file
88
+ if (!stats.isFile()) {
89
+ throw new Error(`Key path ${expandedPath} is not a regular file`);
90
+ }
91
+ } catch (err) {
92
+ if (err instanceof Error && err.message.startsWith("Key")) {
93
+ throw err;
94
+ }
95
+ throw new Error(`Failed to check key file permissions: ${err}`);
96
+ }
97
+ }
98
+
99
+ export function parseConfig(configPath: string): BifrostConfig {
100
+ try {
101
+ if (!existsSync(configPath)) {
102
+ throw new Error(`Config file not found: ${configPath}`);
103
+ }
104
+
105
+ const rawContent = readFileSync(configPath, "utf-8");
106
+ const rawJson = JSON.parse(rawContent);
107
+
108
+ // Expand tilde in keyPath before validation
109
+ if (rawJson.keyPath) {
110
+ rawJson.keyPath = expandTildePath(rawJson.keyPath);
111
+ }
112
+
113
+ const config = BifrostConfigSchema.parse(rawJson);
114
+
115
+ // Validate key file after schema parsing
116
+ validateKeyFile(config.keyPath);
117
+
118
+ return config;
119
+ } catch (err) {
120
+ if (err instanceof Error) {
121
+ if (err.name === "ZodError") {
122
+ const pathMatches = err.message.match(/"path":\s*\[\s*"([^"]+)"/g);
123
+ if (pathMatches) {
124
+ const fields = pathMatches
125
+ .map((match) => {
126
+ const fieldMatch = match.match(/"([^"]+)"$/);
127
+ return fieldMatch ? fieldMatch[1] : null;
128
+ })
129
+ .filter((f) => f !== null) as string[];
130
+
131
+ const uniqueFields = Array.from(new Set(fields));
132
+ if (uniqueFields.length > 0) {
133
+ throw new Error(`Missing required field(s): ${uniqueFields.join(", ")}`);
134
+ }
135
+ }
136
+
137
+ throw new Error(`Invalid config: ${err.message}`);
138
+ }
139
+
140
+ if (err.message.startsWith("Key file") || err.message.startsWith("Failed to check")) {
141
+ throw err;
142
+ }
143
+
144
+ if (err instanceof SyntaxError) {
145
+ throw new Error(`JSON parse error: ${err.message}`);
146
+ }
147
+ }
148
+
149
+ throw err;
150
+ }
151
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1 @@
1
+ export const placeholder = true;
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { bifrostManager } from "./manager";
3
+ import { bifrost_connect } from "./tools/connect";
4
+ import { bifrost_exec } from "./tools/exec";
5
+ import { bifrost_status } from "./tools/status";
6
+ import { bifrost_disconnect } from "./tools/disconnect";
7
+ import { bifrost_upload } from "./tools/upload";
8
+ import { bifrost_download } from "./tools/download";
9
+
10
+ const Bifrost: Plugin = async (ctx) => {
11
+ // Cleanup stale sockets on plugin init
12
+ bifrostManager.cleanup();
13
+
14
+ return {
15
+ tool: {
16
+ bifrost_connect,
17
+ bifrost_exec,
18
+ bifrost_status,
19
+ bifrost_disconnect,
20
+ bifrost_upload,
21
+ bifrost_download,
22
+ },
23
+ event: async (input) => {
24
+ // On session end → auto-disconnect
25
+ if (input.event.type === "session.deleted") {
26
+ await bifrostManager.disconnect();
27
+ }
28
+ },
29
+ "experimental.chat.system.transform": async (input, output) => {
30
+ output.system.push(`## Bifrost SSH Plugin — Remote Server Access
31
+
32
+ You have access to a remote server via persistent SSH connection. The server is pre-configured in ~/.config/opencode/bifrost.json.
33
+
34
+ ### Available Tools
35
+ - \`bifrost_connect\` — Establish persistent SSH connection (call once per session)
36
+ - \`bifrost_exec\` — Execute any command on the remote server
37
+ - \`bifrost_status\` — Check connection status
38
+ - \`bifrost_disconnect\` — Close the connection
39
+ - \`bifrost_upload\` — Upload a local file to the remote server
40
+ - \`bifrost_download\` — Download a file from the remote server
41
+
42
+ ### When to Use
43
+ Automatically use these tools when the user:
44
+ - Mentions "server", "remote", "deploy", "production", "staging", "VPS", "Hetzner"
45
+ - Asks to test something "on the server" or "remotely"
46
+ - Wants to install, configure, or check something on the remote machine
47
+ - Asks about server status, logs, or running processes
48
+ - Wants to transfer files to/from the server
49
+
50
+ ### Workflow
51
+ 1. Call \`bifrost_connect\` to establish the connection (only needed once, it persists)
52
+ 2. Use \`bifrost_exec\` to run commands (e.g., \`bifrost_exec({command: "docker ps"})\`)
53
+ 3. Use \`bifrost_upload\`/\`bifrost_download\` for file transfers
54
+ 4. The connection auto-disconnects when the session ends
55
+
56
+ ### Important
57
+ - Do NOT use raw \`ssh\` commands — always use bifrost tools
58
+ - The connection is persistent — you don't need to reconnect for each command
59
+ - If you get a connection error, try \`bifrost_connect\` again (it auto-reconnects)`);
60
+ },
61
+ };
62
+ };
63
+
64
+ export default Bifrost;