forge-remote 0.1.0 → 0.1.2
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/firestore.rules +100 -0
- package/package.json +4 -2
- package/src/cli.js +33 -14
- package/src/cloudflared-installer.js +164 -0
- package/src/desktop.js +17 -4
- package/src/google-auth.js +334 -0
- package/src/init.js +528 -261
- package/src/session-manager.js +116 -14
- package/src/tunnel-manager.js +226 -33
package/firestore.rules
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
rules_version = '2';
|
|
2
|
+
|
|
3
|
+
service cloud.firestore {
|
|
4
|
+
match /databases/{database}/documents {
|
|
5
|
+
|
|
6
|
+
// Helper: request is from an authenticated user (including anonymous).
|
|
7
|
+
// In BYOF mode, the user owns their own Firebase project, so anonymous
|
|
8
|
+
// auth IS the auth method — there is no "public" access.
|
|
9
|
+
function isSignedIn() {
|
|
10
|
+
return request.auth != null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Helper: request is from the document owner.
|
|
14
|
+
function isOwner(uid) {
|
|
15
|
+
return request.auth != null && request.auth.uid == uid;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Helper: enforce reasonable document size.
|
|
19
|
+
function isValidSize() {
|
|
20
|
+
return request.resource.data.keys().size() < 50;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- Desktops ----
|
|
24
|
+
match /desktops/{desktopId} {
|
|
25
|
+
allow read: if isSignedIn();
|
|
26
|
+
allow create: if isSignedIn()
|
|
27
|
+
&& request.resource.data.keys().hasAll(['ownerUid', 'hostname', 'platform']);
|
|
28
|
+
|
|
29
|
+
// BYOF: user owns the entire Firebase project, so any authenticated
|
|
30
|
+
// user can update or delete desktops. No multi-user ownership needed.
|
|
31
|
+
allow update: if isSignedIn() && isValidSize();
|
|
32
|
+
allow delete: if isSignedIn();
|
|
33
|
+
|
|
34
|
+
// ---- Desktop commands (subcollection) ----
|
|
35
|
+
match /commands/{commandId} {
|
|
36
|
+
allow read: if isSignedIn();
|
|
37
|
+
allow create: if isSignedIn();
|
|
38
|
+
allow update: if isSignedIn();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- Sessions ----
|
|
43
|
+
match /sessions/{sessionId} {
|
|
44
|
+
allow read: if isSignedIn();
|
|
45
|
+
allow create: if isSignedIn()
|
|
46
|
+
&& request.resource.data.keys().hasAll(['ownerUid', 'desktopId', 'status']);
|
|
47
|
+
allow update: if isSignedIn()
|
|
48
|
+
&& isValidSize();
|
|
49
|
+
|
|
50
|
+
// ---- Messages (subcollection) ----
|
|
51
|
+
match /messages/{messageId} {
|
|
52
|
+
allow read: if isSignedIn();
|
|
53
|
+
allow create: if isSignedIn()
|
|
54
|
+
&& request.resource.data.size() < 500000; // 500KB max per message
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- Commands (subcollection) ----
|
|
58
|
+
match /commands/{commandId} {
|
|
59
|
+
allow read: if isSignedIn();
|
|
60
|
+
allow create: if isSignedIn();
|
|
61
|
+
allow update: if isSignedIn();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- Permissions (subcollection) ----
|
|
65
|
+
match /permissions/{permId} {
|
|
66
|
+
allow read: if isSignedIn();
|
|
67
|
+
allow update: if isSignedIn();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- Tool calls (subcollection) ----
|
|
71
|
+
match /toolCalls/{toolCallId} {
|
|
72
|
+
allow read: if isSignedIn();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- Pairing tokens ----
|
|
77
|
+
match /pairingTokens/{tokenId} {
|
|
78
|
+
allow read: if isSignedIn();
|
|
79
|
+
allow update: if isSignedIn()
|
|
80
|
+
&& !resource.data.used
|
|
81
|
+
&& request.resource.data.used == true
|
|
82
|
+
&& request.resource.data.keys().hasAll(['used', 'claimedBy'])
|
|
83
|
+
&& request.resource.data.claimedBy == request.auth.uid;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- User profiles (for preferences) ----
|
|
87
|
+
match /users/{userId} {
|
|
88
|
+
allow read, write: if request.auth != null && request.auth.uid == userId;
|
|
89
|
+
|
|
90
|
+
match /fcmTokens/{tokenId} {
|
|
91
|
+
allow read, write: if request.auth != null && request.auth.uid == userId;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Deny everything else by default.
|
|
96
|
+
match /{document=**} {
|
|
97
|
+
allow read, write: if false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -21,12 +21,14 @@
|
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"src/",
|
|
24
|
+
"firestore.rules",
|
|
24
25
|
"LICENSE",
|
|
25
26
|
"README.md"
|
|
26
27
|
],
|
|
27
28
|
"scripts": {
|
|
28
29
|
"start": "node src/cli.js start",
|
|
29
|
-
"pair": "node src/cli.js pair"
|
|
30
|
+
"pair": "node src/cli.js pair",
|
|
31
|
+
"prepublishOnly": "cp ../firestore.rules ./firestore.rules"
|
|
30
32
|
},
|
|
31
33
|
"keywords": [
|
|
32
34
|
"claude",
|
package/src/cli.js
CHANGED
|
@@ -36,7 +36,7 @@ import * as log from "./logger.js";
|
|
|
36
36
|
program
|
|
37
37
|
.name("forge-remote")
|
|
38
38
|
.description("Desktop relay for Forge Remote")
|
|
39
|
-
.version("0.1.
|
|
39
|
+
.version("0.1.1");
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Load Firebase web SDK config.
|
|
@@ -74,19 +74,38 @@ function loadSdkConfig() {
|
|
|
74
74
|
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
|
|
75
75
|
);
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
77
|
+
// Output may be JSON (quoted keys) or JS object (unquoted keys).
|
|
78
|
+
let config = null;
|
|
79
|
+
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
|
80
|
+
if (jsonMatch) {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
83
|
+
config = {
|
|
84
|
+
apiKey: parsed.apiKey,
|
|
85
|
+
authDomain: parsed.authDomain,
|
|
86
|
+
projectId: parsed.projectId,
|
|
87
|
+
storageBucket: parsed.storageBucket,
|
|
88
|
+
messagingSenderId: parsed.messagingSenderId,
|
|
89
|
+
appId: parsed.appId,
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
// Not valid JSON — fall back to regex.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!config) {
|
|
96
|
+
const parse = (field) => {
|
|
97
|
+
const m = output.match(new RegExp(`"?${field}"?:\\s*"([^"]+)"`));
|
|
98
|
+
return m ? m[1] : null;
|
|
99
|
+
};
|
|
100
|
+
config = {
|
|
101
|
+
apiKey: parse("apiKey"),
|
|
102
|
+
authDomain: parse("authDomain"),
|
|
103
|
+
projectId: parse("projectId"),
|
|
104
|
+
storageBucket: parse("storageBucket"),
|
|
105
|
+
messagingSenderId: parse("messagingSenderId"),
|
|
106
|
+
appId: parse("appId"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
90
109
|
|
|
91
110
|
if (!config.apiKey || !config.projectId) return null;
|
|
92
111
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Forge Remote Relay — Desktop Agent for Forge Remote
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
+
// AGPL-3.0 License — See LICENSE
|
|
5
|
+
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
chmodSync,
|
|
11
|
+
createWriteStream,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
} from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { homedir, platform, arch } from "os";
|
|
16
|
+
import { get as httpsGet } from "https";
|
|
17
|
+
|
|
18
|
+
const BASE_URL =
|
|
19
|
+
"https://github.com/cloudflare/cloudflared/releases/latest/download";
|
|
20
|
+
|
|
21
|
+
const PLATFORM_URLS = {
|
|
22
|
+
"darwin-arm64": `${BASE_URL}/cloudflared-darwin-arm64.tgz`,
|
|
23
|
+
"darwin-x64": `${BASE_URL}/cloudflared-darwin-amd64.tgz`,
|
|
24
|
+
"linux-x64": `${BASE_URL}/cloudflared-linux-amd64`,
|
|
25
|
+
"linux-arm64": `${BASE_URL}/cloudflared-linux-arm64`,
|
|
26
|
+
"win32-x64": `${BASE_URL}/cloudflared-windows-amd64.exe`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const BIN_DIR = join(homedir(), ".forge-remote", "bin");
|
|
30
|
+
|
|
31
|
+
function getBinaryName() {
|
|
32
|
+
return platform() === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Return the absolute path to the local cloudflared binary, or null
|
|
37
|
+
* if it hasn't been installed yet.
|
|
38
|
+
*/
|
|
39
|
+
export function getLocalCloudflaredPath() {
|
|
40
|
+
const binPath = join(BIN_DIR, getBinaryName());
|
|
41
|
+
return existsSync(binPath) ? binPath : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if cloudflared is functional by running `cloudflared --version`.
|
|
46
|
+
*/
|
|
47
|
+
export function isCloudflaredWorking(binaryPath) {
|
|
48
|
+
try {
|
|
49
|
+
execSync(`"${binaryPath}" --version`, {
|
|
50
|
+
stdio: "pipe",
|
|
51
|
+
timeout: 10_000,
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Download a file from a URL, following up to 5 redirects.
|
|
61
|
+
* Returns a promise that resolves to the destination file path.
|
|
62
|
+
*/
|
|
63
|
+
function downloadFile(url, destPath, redirectsLeft = 5) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
if (redirectsLeft <= 0) {
|
|
66
|
+
return reject(new Error("Too many redirects"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
httpsGet(url, (res) => {
|
|
70
|
+
// Follow redirects (GitHub releases use 301/302).
|
|
71
|
+
if (
|
|
72
|
+
(res.statusCode === 301 || res.statusCode === 302) &&
|
|
73
|
+
res.headers.location
|
|
74
|
+
) {
|
|
75
|
+
res.resume(); // Drain the response.
|
|
76
|
+
return resolve(
|
|
77
|
+
downloadFile(res.headers.location, destPath, redirectsLeft - 1),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (res.statusCode !== 200) {
|
|
82
|
+
res.resume();
|
|
83
|
+
return reject(
|
|
84
|
+
new Error(`Download failed: HTTP ${res.statusCode} from ${url}`),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const file = createWriteStream(destPath);
|
|
89
|
+
res.pipe(file);
|
|
90
|
+
file.on("finish", () => {
|
|
91
|
+
file.close(() => resolve(destPath));
|
|
92
|
+
});
|
|
93
|
+
file.on("error", (err) => {
|
|
94
|
+
unlinkSync(destPath);
|
|
95
|
+
reject(err);
|
|
96
|
+
});
|
|
97
|
+
}).on("error", reject);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Download the cloudflared binary for the current platform to
|
|
103
|
+
* ~/.forge-remote/bin/cloudflared. No-op if already present and functional.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<string>} Absolute path to the cloudflared binary.
|
|
106
|
+
* @throws {Error} If the platform is unsupported or download fails.
|
|
107
|
+
*/
|
|
108
|
+
export async function installCloudflared() {
|
|
109
|
+
const binPath = join(BIN_DIR, getBinaryName());
|
|
110
|
+
|
|
111
|
+
// Skip if already installed and working.
|
|
112
|
+
if (existsSync(binPath) && isCloudflaredWorking(binPath)) {
|
|
113
|
+
return binPath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const key = `${platform()}-${arch()}`;
|
|
117
|
+
const url = PLATFORM_URLS[key];
|
|
118
|
+
if (!url) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Unsupported platform: ${key}. ` +
|
|
121
|
+
`Install cloudflared manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Ensure bin directory exists.
|
|
126
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
127
|
+
|
|
128
|
+
const isTarball = url.endsWith(".tgz");
|
|
129
|
+
|
|
130
|
+
if (isTarball) {
|
|
131
|
+
// macOS: download tarball, extract the binary.
|
|
132
|
+
const tarPath = join(BIN_DIR, "cloudflared.tgz");
|
|
133
|
+
await downloadFile(url, tarPath);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
execSync(`tar xzf "${tarPath}" -C "${BIN_DIR}"`, { stdio: "pipe" });
|
|
137
|
+
} finally {
|
|
138
|
+
// Clean up tarball.
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(tarPath);
|
|
141
|
+
} catch {
|
|
142
|
+
// Ignore cleanup failure.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
// Linux/Windows: download binary directly.
|
|
147
|
+
await downloadFile(url, binPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Make executable (not needed on Windows).
|
|
151
|
+
if (platform() !== "win32") {
|
|
152
|
+
chmodSync(binPath, 0o755);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Verify it works.
|
|
156
|
+
if (!isCloudflaredWorking(binPath)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"cloudflared was downloaded but failed verification. " +
|
|
159
|
+
"Try installing manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return binPath;
|
|
164
|
+
}
|
package/src/desktop.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getDb, FieldValue, Timestamp } from "./firebase.js";
|
|
2
|
-
import { hostname, platform } from "os";
|
|
2
|
+
import { hostname, platform, homedir } from "os";
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
3
5
|
import { v4 as uuidv4 } from "uuid";
|
|
4
6
|
import * as log from "./logger.js";
|
|
5
7
|
|
|
@@ -102,11 +104,22 @@ export async function createPairingToken(desktopId) {
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/**
|
|
105
|
-
* Get a stable desktop ID
|
|
106
|
-
*
|
|
107
|
+
* Get a stable desktop ID.
|
|
108
|
+
* Reads from ~/.forge-remote/config.json (written by `init`) so the ID
|
|
109
|
+
* stays consistent even when macOS changes the hostname across networks.
|
|
110
|
+
* Falls back to hostname-based ID if config doesn't exist yet.
|
|
107
111
|
*/
|
|
108
112
|
export function getDesktopId() {
|
|
109
|
-
|
|
113
|
+
const configPath = join(homedir(), ".forge-remote", "config.json");
|
|
114
|
+
if (existsSync(configPath)) {
|
|
115
|
+
try {
|
|
116
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
117
|
+
if (config.desktopId) return config.desktopId;
|
|
118
|
+
} catch {
|
|
119
|
+
// Fall through to hostname-based.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
const name = hostname()
|
|
111
124
|
.toLowerCase()
|
|
112
125
|
.replace(/[^a-z0-9]/g, "-");
|