pi-bridge 0.1.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.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # pi-bridge
2
+
3
+ Transparent filesystem bridge that makes [pi](https://github.com/badlogic/pi-mono) run natively on a remote machine — no internet required on the remote, no tools to install beyond Node.js.
4
+
5
+ Pi runs on your local machine (needs internet for auth and Claude API). Your project lives on a remote Linux machine. `pi-bridge` intercepts all of pi's filesystem calls and redirects them over SSH to a tiny HTTP server running on the remote. Pi thinks it's running locally — it finds `AGENTS.md`, skills, context files, and git state natively, without knowing they're remote.
6
+
7
+ ## Prerequisites
8
+
9
+ - Node.js ≥ 18 on the **local** machine
10
+ - Node.js on the **remote** machine
11
+ - SSH access to the remote machine with key-based auth (no password prompt)
12
+ - `pi` installed locally
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install -g pi-bridge
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ Use `pii` exactly like `pi`, adding `--ssh` to point at your remote project:
23
+
24
+ ```bash
25
+ # Absolute path
26
+ pii --ssh user@host:/root/projects/my-app
27
+
28
+ # With SSH alias (configured in ~/.ssh/config)
29
+ pii --ssh myserver:/root/projects/my-app
30
+
31
+ # Tilde path (expanded by remote shell)
32
+ pii --ssh user@host:~/projects/my-app
33
+ ```
34
+
35
+ Everything works natively — pi discovers `AGENTS.md`, skills, and context files on the remote without any extra flags or extensions.
36
+
37
+ ## Updating
38
+
39
+ ```bash
40
+ npm update -g pi-bridge
41
+ ```
42
+
43
+ ## How It Works
44
+
45
+ ```
46
+ Local machine Remote (Linux)
47
+ ───────────────────────────── ──────────────────────
48
+ pii (bin/pii.js)
49
+ └─ node --require preload.js pi
50
+ ├─ upload server bundle via SSH
51
+ ├─ start HTTP server on remote
52
+ │ → prints PORT:TOKEN:PID to stdout
53
+ ├─ SSH port forward localhost:PORT
54
+ ├─ create fake local dir:
55
+ │ /tmp/pi-bridge/root/projects/my-app
56
+ ├─ process.chdir(fakeLocalCwd)
57
+ ├─ strip --ssh from process.argv
58
+ ├─ patch fs.* (sync + promises) remote/index.js
59
+ └─ patch child_process.* listens on 127.0.0.1:PORT
60
+ validates token
61
+ pi loads — unaware of bridge restricts to remoteCwd
62
+ fs.readFileSync(...) ──GET──→ reads real remote files
63
+ fs.readdirSync(...) ──GET──→ ← returns data
64
+ spawn('git', ...) ──SSH──→ runs on remote
65
+ → finds AGENTS.md ✓
66
+ → finds skills ✓
67
+ → git branch in footer ✓
68
+ ```
69
+
70
+ ## What Gets Patched
71
+
72
+ | API | Covered |
73
+ |---|---|
74
+ | `fs` sync — read, write, stat, readdir, mkdir, rm, copy, rename, realpath, access, watch | ✓ |
75
+ | `fs.promises` — all async equivalents | ✓ |
76
+ | `fs/promises` module (ES module static imports) | ✓ |
77
+ | `child_process.spawn` / `spawnSync` — runs in remote cwd via SSH | ✓ |
78
+ | `fs.watch` / `fs.watchFile` — polls remote via HTTP (git branch updates) | ✓ |
79
+
80
+ ## Security
81
+
82
+ | Layer | Protection |
83
+ |---|---|
84
+ | Loopback only | HTTP server binds `127.0.0.1` — not reachable from the network |
85
+ | SSH tunnel | All HTTP traffic encrypted end-to-end |
86
+ | Random token | 32-byte random token required on every request |
87
+ | Path restriction | Server rejects paths outside `remoteCwd` (403) |
88
+ | Token in stdout | Token never appears in `ps aux` args |
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ git clone https://github.com/zeflq/pi-bridge
94
+ cd pi-bridge
95
+ npm install
96
+ ```
97
+
98
+ Test against a real remote without installing pi:
99
+
100
+ ```bash
101
+ SSH_TARGET=user@host:/root/projects/my-app npm run dev
102
+ ```
103
+
104
+ Run tests:
105
+
106
+ ```bash
107
+ npm test
108
+ ```
109
+
110
+ ## Troubleshooting
111
+
112
+ **`Command failed: ssh … syntax error near unexpected token`**
113
+ Your SSH alias requires `BatchMode=yes`. Add to `~/.ssh/config`:
114
+ ```
115
+ Host myserver
116
+ BatchMode yes
117
+ ```
118
+
119
+ **Files show as empty / ENOENT**
120
+ The fake local directory (`/tmp/pi-bridge/…`) is intentionally empty — all reads go to the remote. If you see ENOENT, the bridge may not have started correctly. Check that Node.js is available on the remote (`node --version`).
121
+
122
+ **Branch name not updating**
123
+ The footer polls `.git` every second. Switching branches from outside a pi session is reflected within ~1s. If the branch is stuck, restart `pii`.
124
+
125
+ **Windows: `pii` can't find `pi`**
126
+ Ensure the OpenSSH client is enabled (Windows 10+: Settings → Apps → Optional Features → OpenSSH Client) and `pi` is installed globally (`npm install -g pi`).
package/bin/pii.js ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * pii — pi-bridge entry point installed as a global binary.
6
+ *
7
+ * Locates the `pi` CLI JS file and its bundled Node binary (if any), then
8
+ * respawns with --require preload.js so the bridge is active before pi loads.
9
+ *
10
+ * Resolution strategy (same on all platforms):
11
+ * 1. Get the wrapper script path from the system (which pi / Get-Command pi)
12
+ * 2. Derive basedir from the wrapper path
13
+ * 3. Find the actual CLI JS at basedir/node_modules/@mariozechner/pi-coding-agent/dist/cli.js
14
+ * 4. Use basedir/node.exe if present (pi ships its own Node on some installs),
15
+ * otherwise fall back to the current Node binary
16
+ */
17
+
18
+ const { spawnSync, execFileSync } = require('child_process');
19
+ const path = require('path');
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+
23
+ const preload = path.join(__dirname, '..', 'src', 'local', 'preload.js');
24
+
25
+ const PI_CLI_REL = path.join('node_modules', '@mariozechner', 'pi-coding-agent', 'dist', 'cli.js');
26
+
27
+ function getWrapperPath() {
28
+ if (os.platform() === 'win32') {
29
+ return execFileSync(
30
+ 'powershell',
31
+ ['-NoProfile', '-Command', '(Get-Command pi -ErrorAction Stop).Source'],
32
+ { encoding: 'utf8' }
33
+ ).trim();
34
+ }
35
+ return execFileSync('which', ['pi'], { encoding: 'utf8' }).trim();
36
+ }
37
+
38
+ function findPiCli() {
39
+ const wrapper = getWrapperPath();
40
+ const basedir = path.dirname(wrapper);
41
+
42
+ const cli = path.join(basedir, PI_CLI_REL);
43
+ if (!fs.existsSync(cli)) {
44
+ throw new Error(
45
+ 'Could not find pi CLI at ' + cli + '\n' +
46
+ 'Make sure pi is installed: npm install -g @mariozechner/pi-coding-agent'
47
+ );
48
+ }
49
+
50
+ // Prefer the Node binary bundled alongside pi (some installs ship node.exe)
51
+ const localNode = path.join(basedir, 'node.exe');
52
+ const nodeExe = fs.existsSync(localNode) ? localNode : process.execPath;
53
+
54
+ return { cli, nodeExe };
55
+ }
56
+
57
+ let cli, nodeExe;
58
+ try {
59
+ ({ cli, nodeExe } = findPiCli());
60
+ } catch (err) {
61
+ process.stderr.write('pii: ' + err.message + '\n');
62
+ process.exit(1);
63
+ }
64
+
65
+ const result = spawnSync(
66
+ nodeExe,
67
+ ['--require', preload, cli, ...process.argv.slice(2)],
68
+ { stdio: 'inherit' }
69
+ );
70
+
71
+ process.exit(result.status ?? 1);
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pi-bridge",
3
+ "version": "0.1.1",
4
+ "description": "Transparent filesystem bridge that makes pi run natively on a remote machine over SSH",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "pii": "bin/pii.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "scripts": {
14
+ "test": "vitest --run",
15
+ "dev": "node dev.js"
16
+ },
17
+ "engines": {
18
+ "node": ">=18.0.0"
19
+ },
20
+ "keywords": [
21
+ "pi-package",
22
+ "pi",
23
+ "pi-coding-agent",
24
+ "ssh",
25
+ "bridge",
26
+ "remote",
27
+ "filesystem"
28
+ ],
29
+ "license": "MIT",
30
+ "devDependencies": {
31
+ "vitest": "^2.0.0"
32
+ }
33
+ }
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Parse and strip --ssh user@host:/path from process.argv.
5
+ *
6
+ * Mutates process.argv in place, removing both '--ssh' and its value.
7
+ *
8
+ * @returns {{ remote: string, remoteCwd: string } | null}
9
+ * remote = 'user@host'
10
+ * remoteCwd = '/path' (or '~/path' — remote shell will expand it)
11
+ * Returns null if --ssh flag is not present.
12
+ */
13
+ function parseAndStripSshArg() {
14
+ const argv = process.argv;
15
+ const idx = argv.indexOf('--ssh');
16
+ if (idx === -1) return null;
17
+
18
+ const value = argv[idx + 1];
19
+ if (!value) {
20
+ throw new Error('--ssh flag requires a value: user@host:/path');
21
+ }
22
+
23
+ // value format: user@host:/path or user@host:~/path
24
+ const colonIdx = value.indexOf(':');
25
+ if (colonIdx === -1) {
26
+ throw new Error('--ssh value must be in format user@host:/path, got: ' + value);
27
+ }
28
+
29
+ const remote = value.slice(0, colonIdx);
30
+ const remoteCwd = value.slice(colonIdx + 1);
31
+
32
+ if (!remote) {
33
+ throw new Error('--ssh value missing host portion: ' + value);
34
+ }
35
+ if (!remoteCwd) {
36
+ throw new Error('--ssh value missing path portion: ' + value);
37
+ }
38
+
39
+ // Strip both '--ssh' and its value from argv
40
+ argv.splice(idx, 2);
41
+
42
+ return { remote, remoteCwd };
43
+ }
44
+
45
+ module.exports = { parseAndStripSshArg };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ const cp = require('child_process');
4
+ const path = require('path');
5
+ const { isFakePath, toRemotePath } = require('./path-mapper');
6
+
7
+ /**
8
+ * Patch child_process.spawn and child_process.spawnSync so that commands
9
+ * whose cwd lands inside fakeRoot are transparently redirected to run on
10
+ * the remote machine via SSH.
11
+ *
12
+ * Pi's bash tool uses spawn/spawnSync exclusively, so this covers all shell
13
+ * command execution (ls, cat, grep, npm run, etc.).
14
+ *
15
+ * @param {string} fakeRoot - Local fake root directory (real path, no symlinks)
16
+ * @param {string} remote - SSH target, e.g. 'user@host'
17
+ */
18
+ function patchChildProcess(fakeRoot, remote) {
19
+ const origSpawn = cp.spawn.bind(cp);
20
+ const origSpawnSync = cp.spawnSync.bind(cp);
21
+
22
+ /**
23
+ * Resolve the effective cwd from spawn options.
24
+ * Falls back to process.cwd() when opts.cwd is not set.
25
+ */
26
+ function effectiveCwd(opts) {
27
+ const c = opts && opts.cwd;
28
+ if (!c) return process.cwd();
29
+ return typeof c === 'string' ? path.resolve(c) : c.toString();
30
+ }
31
+
32
+ /**
33
+ * Build the SSH-redirected command: cd to remote cwd, then run the
34
+ * original command with its arguments, all shell-quoted.
35
+ */
36
+ function buildSshArgs(file, args, opts) {
37
+ const fakeCwd = effectiveCwd(opts);
38
+ const remoteCwd = toRemotePath(fakeCwd, fakeRoot);
39
+
40
+ // Reconstruct the command string with shell-safe quoting
41
+ const argStr = (args || [])
42
+ .map(function(a) { return "'" + String(a).replace(/'/g, "'\\''") + "'"; })
43
+ .join(' ');
44
+ const remoteCmd = 'cd ' + JSON.stringify(remoteCwd) + ' && ' + file + (argStr ? ' ' + argStr : '');
45
+
46
+ return ['-o', 'BatchMode=yes', remote, remoteCmd];
47
+ }
48
+
49
+ // ── spawn ────────────────────────────────────────────────────────────────────
50
+
51
+ cp.spawn = function patchedSpawn(file, args, opts) {
52
+ // Normalise overloaded signature: spawn(file) / spawn(file, opts) / spawn(file, args, opts)
53
+ if (!Array.isArray(args)) { opts = args; args = []; }
54
+ opts = opts || {};
55
+
56
+ const cwd = effectiveCwd(opts);
57
+ if (!isFakePath(cwd, fakeRoot)) return origSpawn(file, args, opts);
58
+
59
+ return origSpawn('ssh', buildSshArgs(file, args, opts), Object.assign({}, opts, { cwd: undefined }));
60
+ };
61
+
62
+ // ── spawnSync ────────────────────────────────────────────────────────────────
63
+
64
+ cp.spawnSync = function patchedSpawnSync(file, args, opts) {
65
+ if (!Array.isArray(args)) { opts = args; args = []; }
66
+ opts = opts || {};
67
+
68
+ const cwd = effectiveCwd(opts);
69
+ if (!isFakePath(cwd, fakeRoot)) return origSpawnSync(file, args, opts);
70
+
71
+ return origSpawnSync('ssh', buildSshArgs(file, args, opts), Object.assign({}, opts, { cwd: undefined }));
72
+ };
73
+ }
74
+
75
+ module.exports = { patchChildProcess };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const { removeFakeDir } = require('./fake-dir');
4
+ const { sshExecSync } = require('./ssh');
5
+
6
+ /**
7
+ * Register a process.on('exit') handler that tears down the bridge session.
8
+ *
9
+ * Cleanup actions (best-effort — errors are suppressed):
10
+ * 1. Remove the fake local directory
11
+ * 2. Kill the SSH tunnel process
12
+ * 3. Kill the remote server process (by PID) and remove its log file
13
+ *
14
+ * Each session has a unique sessionId so its log file does not clash with
15
+ * other sessions targeting the same remote working directory.
16
+ *
17
+ * @param {object} opts
18
+ * @param {string} opts.fakeRoot - Local fake root path to remove
19
+ * @param {object} opts.tunnelProcess - SSH tunnel watchdog handle ({ kill() })
20
+ * @param {string} opts.remote - SSH target, e.g. 'user@host'
21
+ * @param {string} opts.sessionId - Random 8-char hex ID for this session
22
+ * @param {number} opts.remotePid - PID of the remote server process
23
+ */
24
+ function registerCleanup({ fakeRoot, tunnelProcess, remote, sessionId, remotePid }) {
25
+ const remoteLog = sessionId ? '/tmp/pi-bridge-out-' + sessionId + '.txt' : null;
26
+
27
+ function cleanup() {
28
+ // 1. Remove fake local directory
29
+ if (fakeRoot) {
30
+ removeFakeDir(fakeRoot);
31
+ }
32
+
33
+ // 2. Kill tunnel watchdog
34
+ if (tunnelProcess) {
35
+ try {
36
+ if (typeof tunnelProcess.kill === 'function') tunnelProcess.kill();
37
+ } catch (_) {}
38
+ }
39
+
40
+ // 3. Kill the specific remote server process and clean up its log file
41
+ if (remote) {
42
+ try {
43
+ let cmd = '';
44
+ if (remotePid) cmd += 'kill ' + remotePid + ' 2>/dev/null; ';
45
+ if (remoteLog) cmd += 'rm -f ' + remoteLog;
46
+ if (cmd) sshExecSync(remote, cmd);
47
+ } catch (_) {}
48
+ }
49
+ }
50
+
51
+ process.on('exit', cleanup);
52
+ process.on('SIGINT', () => process.exit(130));
53
+ process.on('SIGTERM', () => process.exit(143));
54
+ }
55
+
56
+ module.exports = { registerCleanup };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ /**
8
+ * Create the fake local directory skeleton for the given remote path.
9
+ * The structure mirrors the remote path so that path-mapper can convert
10
+ * between fake local paths and remote paths deterministically.
11
+ *
12
+ * Example:
13
+ * remoteCwd = '/root/project-x'
14
+ * fakeRoot = '/tmp/pi-bridge'
15
+ * fakeLocalCwd = '/tmp/pi-bridge/root/project-x'
16
+ *
17
+ * @param {string} remoteCwd - Absolute remote path, e.g. '/root/project-x'
18
+ * @returns {{ fakeRoot: string, fakeLocalCwd: string }}
19
+ */
20
+ function createFakeDir(remoteCwd) {
21
+ const fakeRootRaw = path.join(os.tmpdir(), 'pi-bridge');
22
+
23
+ // Build the local mirror path from the remote path segments
24
+ const segments = remoteCwd.split('/').filter(Boolean);
25
+ const fakeLocalCwdRaw = path.join(fakeRootRaw, ...segments);
26
+
27
+ fs.mkdirSync(fakeLocalCwdRaw, { recursive: true });
28
+
29
+ // Resolve symlinks so the prefix matches what process.cwd() returns after
30
+ // chdir — on macOS /var/folders/… is a symlink to /private/var/folders/…
31
+ const fakeRoot = fs.realpathSync(fakeRootRaw);
32
+ const fakeLocalCwd = fs.realpathSync(fakeLocalCwdRaw);
33
+
34
+ return { fakeRoot, fakeLocalCwd };
35
+ }
36
+
37
+ /**
38
+ * Remove the fake root directory and all its contents.
39
+ * Safe to call even if the directory does not exist.
40
+ *
41
+ * @param {string} fakeRoot - Path returned by createFakeDir
42
+ */
43
+ function removeFakeDir(fakeRoot) {
44
+ try {
45
+ fs.rmSync(fakeRoot, { recursive: true, force: true });
46
+ } catch (_) {
47
+ // Best-effort removal — do not throw during cleanup
48
+ }
49
+ }
50
+
51
+ module.exports = { createFakeDir, removeFakeDir };