forge-remote 0.1.2 → 0.1.3
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/package.json +2 -3
- package/src/cli.js +8 -5
- package/src/cloudflared-installer.js +0 -1
- package/src/google-auth.js +147 -45
- package/src/init.js +86 -30
- package/src/project-scanner.js +116 -50
- package/src/session-manager.js +263 -13
- package/src/tunnel-manager.js +2 -2
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
7
|
"author": "Daniel Wendel <daniel@ironforgeapps.com> (https://ironforgeapps.com)",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"src/",
|
|
24
24
|
"firestore.rules",
|
|
25
|
-
"LICENSE",
|
|
26
25
|
"README.md"
|
|
27
26
|
],
|
|
28
27
|
"scripts": {
|
package/src/cli.js
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
// Forge Remote Relay — Desktop Agent for Forge Remote
|
|
4
4
|
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
5
5
|
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
6
|
-
// AGPL-3.0 License — See LICENSE
|
|
7
6
|
|
|
8
7
|
import { program } from "commander";
|
|
9
|
-
import {
|
|
8
|
+
import { execFileSync } from "child_process";
|
|
10
9
|
import { readFileSync, existsSync, writeFileSync } from "fs";
|
|
11
10
|
import { join } from "path";
|
|
12
11
|
import { hostname, homedir } from "os";
|
|
@@ -67,10 +66,14 @@ function loadSdkConfig() {
|
|
|
67
66
|
return null;
|
|
68
67
|
}
|
|
69
68
|
|
|
69
|
+
// Validate project ID before using it in any command.
|
|
70
|
+
if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) return null;
|
|
71
|
+
|
|
70
72
|
// 3. Fetch via Firebase CLI.
|
|
71
73
|
try {
|
|
72
|
-
const output =
|
|
73
|
-
|
|
74
|
+
const output = execFileSync(
|
|
75
|
+
"firebase",
|
|
76
|
+
["apps:sdkconfig", "web", "--project", projectId],
|
|
74
77
|
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
|
|
75
78
|
);
|
|
76
79
|
|
|
@@ -110,7 +113,7 @@ function loadSdkConfig() {
|
|
|
110
113
|
if (!config.apiKey || !config.projectId) return null;
|
|
111
114
|
|
|
112
115
|
// Cache for next time.
|
|
113
|
-
writeFileSync(cachedPath, JSON.stringify(config, null, 2));
|
|
116
|
+
writeFileSync(cachedPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
114
117
|
return config;
|
|
115
118
|
} catch {
|
|
116
119
|
return null;
|
package/src/google-auth.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Forge Remote Relay — Desktop Agent for Forge Remote
|
|
2
2
|
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
3
|
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
-
// AGPL-3.0 License — See LICENSE
|
|
5
4
|
|
|
6
5
|
import { readFileSync, existsSync } from "fs";
|
|
7
6
|
import { join } from "path";
|
|
@@ -86,28 +85,66 @@ export async function getAccessToken() {
|
|
|
86
85
|
/**
|
|
87
86
|
* Enable Anonymous Authentication for the given Firebase project.
|
|
88
87
|
* Uses the Identity Toolkit Admin v2 API.
|
|
88
|
+
*
|
|
89
|
+
* If the project hasn't had Auth initialized yet (CONFIGURATION_NOT_FOUND),
|
|
90
|
+
* we first initialize the Identity Platform, then enable Anonymous.
|
|
89
91
|
*/
|
|
90
92
|
export async function enableAnonymousAuth(projectId) {
|
|
91
93
|
const token = await getAccessToken();
|
|
92
94
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
const patchAuth = async () => {
|
|
96
|
+
return fetch(
|
|
97
|
+
`https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/config?updateMask=signIn.anonymous.enabled`,
|
|
98
|
+
{
|
|
99
|
+
method: "PATCH",
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${token}`,
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
"X-Goog-User-Project": projectId,
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
signIn: {
|
|
107
|
+
anonymous: {
|
|
108
|
+
enabled: true,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
101
112
|
},
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let response = await patchAuth();
|
|
117
|
+
|
|
118
|
+
// If config doesn't exist yet, initialize Identity Platform first.
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const err = await response.json().catch(() => ({}));
|
|
121
|
+
const errMsg = err.error?.message || "";
|
|
122
|
+
|
|
123
|
+
if (errMsg.includes("CONFIGURATION_NOT_FOUND")) {
|
|
124
|
+
// Initialize Identity Platform for this project.
|
|
125
|
+
const initResp = await fetch(
|
|
126
|
+
`https://identitytoolkit.googleapis.com/v2/projects/${projectId}/identityPlatform:initializeAuth`,
|
|
127
|
+
{
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${token}`,
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
"X-Goog-User-Project": projectId,
|
|
106
133
|
},
|
|
107
134
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!initResp.ok) {
|
|
138
|
+
const initErr = await initResp.json().catch(() => ({}));
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Failed to initialize Identity Platform: ${initErr.error?.message || initResp.statusText}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Retry enabling Anonymous Auth after initialization.
|
|
145
|
+
response = await patchAuth();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
111
148
|
|
|
112
149
|
if (!response.ok) {
|
|
113
150
|
const err = await response.json().catch(() => ({}));
|
|
@@ -119,6 +156,64 @@ export async function enableAnonymousAuth(projectId) {
|
|
|
119
156
|
return true;
|
|
120
157
|
}
|
|
121
158
|
|
|
159
|
+
// ─── API Key Management ─────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Remove browser-only restrictions from the project's API key.
|
|
163
|
+
*
|
|
164
|
+
* Firebase creates a "Browser key" with browserKeyRestrictions, which blocks
|
|
165
|
+
* requests from mobile apps (they don't send HTTP referrers). We keep the
|
|
166
|
+
* API target restrictions but remove the platform restriction so the key
|
|
167
|
+
* works from iOS and Android.
|
|
168
|
+
*/
|
|
169
|
+
export async function removeBrowserKeyRestriction(projectId, projectNumber) {
|
|
170
|
+
const token = await getAccessToken();
|
|
171
|
+
|
|
172
|
+
// List all keys for the project.
|
|
173
|
+
const listResp = await fetch(
|
|
174
|
+
`https://apikeys.googleapis.com/v2/projects/${projectNumber}/locations/global/keys`,
|
|
175
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (!listResp.ok) {
|
|
179
|
+
throw new Error("Failed to list API keys.");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const listData = await listResp.json();
|
|
183
|
+
const keys = listData.keys || [];
|
|
184
|
+
|
|
185
|
+
for (const key of keys) {
|
|
186
|
+
if (key.restrictions?.browserKeyRestrictions) {
|
|
187
|
+
// Remove browser restriction but keep API target restrictions.
|
|
188
|
+
const updateResp = await fetch(
|
|
189
|
+
`https://apikeys.googleapis.com/v2/${key.name}?updateMask=restrictions`,
|
|
190
|
+
{
|
|
191
|
+
method: "PATCH",
|
|
192
|
+
headers: {
|
|
193
|
+
Authorization: `Bearer ${token}`,
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
restrictions: {
|
|
198
|
+
apiTargets: key.restrictions.apiTargets || [],
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (!updateResp.ok) {
|
|
205
|
+
const err = await updateResp.json().catch(() => ({}));
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Failed to update API key: ${err.error?.message || updateResp.statusText}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return false; // No browser-restricted keys found.
|
|
215
|
+
}
|
|
216
|
+
|
|
122
217
|
// ─── Google Cloud API Enablement ─────────────────────────────────────────────
|
|
123
218
|
|
|
124
219
|
/**
|
|
@@ -252,9 +347,11 @@ export async function createServiceAccountKey(projectId, serviceAccountEmail) {
|
|
|
252
347
|
/**
|
|
253
348
|
* Deploy Firestore security rules via the Firebase Rules REST API.
|
|
254
349
|
*
|
|
255
|
-
*
|
|
350
|
+
* Three-step process:
|
|
256
351
|
* 1. Create a new ruleset containing the rules source.
|
|
257
|
-
* 2. Update (or create) the `cloud.firestore` release
|
|
352
|
+
* 2. Update (or create) the `cloud.firestore` release for the (default) database.
|
|
353
|
+
* 3. Also deploy to `cloud.firestore/database/default` for the named "default"
|
|
354
|
+
* database (firebase CLI creates a named db, not the (default) alias).
|
|
258
355
|
*/
|
|
259
356
|
export async function deployFirestoreRules(projectId, rulesContent) {
|
|
260
357
|
const token = await getAccessToken();
|
|
@@ -293,41 +390,46 @@ export async function deployFirestoreRules(projectId, rulesContent) {
|
|
|
293
390
|
const ruleset = await rulesetResponse.json();
|
|
294
391
|
const rulesetName = ruleset.name;
|
|
295
392
|
|
|
296
|
-
// Step 2:
|
|
297
|
-
const
|
|
298
|
-
const releaseBody = {
|
|
299
|
-
release: {
|
|
300
|
-
name: releaseName,
|
|
301
|
-
rulesetName: rulesetName,
|
|
302
|
-
},
|
|
303
|
-
};
|
|
393
|
+
// Step 2: Deploy the ruleset as the release for the (default) database.
|
|
394
|
+
const releaseNames = [`projects/${projectId}/releases/cloud.firestore`];
|
|
304
395
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
method: "PATCH",
|
|
309
|
-
headers,
|
|
310
|
-
body: JSON.stringify(releaseBody),
|
|
311
|
-
},
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
if (!releaseResponse.ok && releaseResponse.status === 404) {
|
|
315
|
-
// Release doesn't exist yet — create it
|
|
316
|
-
releaseResponse = await fetch(
|
|
396
|
+
for (const releaseName of releaseNames) {
|
|
397
|
+
// Try creating the release first (works for new projects).
|
|
398
|
+
let releaseResponse = await fetch(
|
|
317
399
|
`https://firebaserules.googleapis.com/v1/projects/${projectId}/releases`,
|
|
318
400
|
{
|
|
319
401
|
method: "POST",
|
|
320
402
|
headers,
|
|
321
|
-
body: JSON.stringify(
|
|
403
|
+
body: JSON.stringify({
|
|
404
|
+
name: releaseName,
|
|
405
|
+
rulesetName: rulesetName,
|
|
406
|
+
}),
|
|
322
407
|
},
|
|
323
408
|
);
|
|
324
|
-
}
|
|
325
409
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
410
|
+
if (!releaseResponse.ok && releaseResponse.status === 409) {
|
|
411
|
+
// Release already exists — update it via PATCH (requires wrapper + updateMask).
|
|
412
|
+
releaseResponse = await fetch(
|
|
413
|
+
`https://firebaserules.googleapis.com/v1/${releaseName}?updateMask=rulesetName`,
|
|
414
|
+
{
|
|
415
|
+
method: "PATCH",
|
|
416
|
+
headers,
|
|
417
|
+
body: JSON.stringify({
|
|
418
|
+
release: {
|
|
419
|
+
name: releaseName,
|
|
420
|
+
rulesetName: rulesetName,
|
|
421
|
+
},
|
|
422
|
+
}),
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!releaseResponse.ok) {
|
|
428
|
+
const err = await releaseResponse.json().catch(() => ({}));
|
|
429
|
+
throw new Error(
|
|
430
|
+
`Failed to deploy rules release: ${err.error?.message || releaseResponse.statusText}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
331
433
|
}
|
|
332
434
|
|
|
333
435
|
return true;
|
package/src/init.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Forge Remote Relay — Desktop Agent for Forge Remote
|
|
2
2
|
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
3
|
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
-
// AGPL-3.0 License — See LICENSE
|
|
5
4
|
|
|
6
|
-
import { execSync } from "child_process";
|
|
5
|
+
import { execSync, execFileSync } from "child_process";
|
|
7
6
|
import { createInterface } from "readline";
|
|
8
7
|
import { hostname, platform, homedir } from "os";
|
|
9
8
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
|
|
@@ -21,6 +20,7 @@ import {
|
|
|
21
20
|
findFirebaseAdminSdkAccount,
|
|
22
21
|
createServiceAccountKey,
|
|
23
22
|
deployFirestoreRules,
|
|
23
|
+
removeBrowserKeyRestriction,
|
|
24
24
|
} from "./google-auth.js";
|
|
25
25
|
import {
|
|
26
26
|
installCloudflared,
|
|
@@ -30,12 +30,14 @@ import {
|
|
|
30
30
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Run a shell command
|
|
34
|
-
*
|
|
33
|
+
* Run a shell command safely using execFileSync (no shell interpolation).
|
|
34
|
+
* Pass command as [binary, ...args] array.
|
|
35
|
+
* Returns stdout (trimmed) or null on failure.
|
|
35
36
|
*/
|
|
36
|
-
function run(
|
|
37
|
+
function run(args, { silent = false } = {}) {
|
|
37
38
|
try {
|
|
38
|
-
|
|
39
|
+
const [cmd, ...rest] = Array.isArray(args) ? args : [args];
|
|
40
|
+
return execFileSync(cmd, rest, {
|
|
39
41
|
encoding: "utf-8",
|
|
40
42
|
stdio: silent ? "pipe" : ["pipe", "pipe", "pipe"],
|
|
41
43
|
}).trim();
|
|
@@ -45,17 +47,24 @@ function run(cmd, { silent = false } = {}) {
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
|
-
* Run a shell command, throwing on failure with a descriptive message.
|
|
50
|
+
* Run a shell command safely, throwing on failure with a descriptive message.
|
|
51
|
+
* Pass command as [binary, ...args] array.
|
|
49
52
|
*/
|
|
50
|
-
function runOrFail(
|
|
53
|
+
function runOrFail(args, errorMsg) {
|
|
51
54
|
try {
|
|
52
|
-
|
|
55
|
+
const [cmd, ...rest] = Array.isArray(args) ? args : [args];
|
|
56
|
+
return execFileSync(cmd, rest, {
|
|
53
57
|
encoding: "utf-8",
|
|
54
58
|
stdio: ["pipe", "pipe", "pipe"],
|
|
55
59
|
}).trim();
|
|
56
60
|
} catch (e) {
|
|
57
|
-
const stderr = e.stderr ? e.stderr.toString().trim() :
|
|
58
|
-
|
|
61
|
+
const stderr = e.stderr ? e.stderr.toString().trim() : "";
|
|
62
|
+
const stdout = e.stdout ? e.stdout.toString().trim() : "";
|
|
63
|
+
const details = stderr || stdout || e.message;
|
|
64
|
+
const err = new Error(`${errorMsg}\n Command: ${args}\n ${details}`);
|
|
65
|
+
err.stdout = stdout;
|
|
66
|
+
err.stderr = stderr;
|
|
67
|
+
throw err;
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
|
|
@@ -113,9 +122,10 @@ function waitForEnter(message) {
|
|
|
113
122
|
function openBrowser(url) {
|
|
114
123
|
const p = platform();
|
|
115
124
|
try {
|
|
116
|
-
if (p === "darwin")
|
|
117
|
-
else if (p === "win32")
|
|
118
|
-
|
|
125
|
+
if (p === "darwin") execFileSync("open", [url], { stdio: "pipe" });
|
|
126
|
+
else if (p === "win32")
|
|
127
|
+
execFileSync("cmd", ["/c", "start", "", url], { stdio: "pipe" });
|
|
128
|
+
else execFileSync("xdg-open", [url], { stdio: "pipe" });
|
|
119
129
|
} catch {
|
|
120
130
|
// Silently fail — URL is always printed for manual opening.
|
|
121
131
|
}
|
|
@@ -184,7 +194,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
184
194
|
|
|
185
195
|
console.log(chalk.bold("\n📋 Step 1/11 — Checking Firebase CLI...\n"));
|
|
186
196
|
|
|
187
|
-
const firebaseVersion = run("firebase --version", { silent: true });
|
|
197
|
+
const firebaseVersion = run(["firebase", "--version"], { silent: true });
|
|
188
198
|
if (!firebaseVersion) {
|
|
189
199
|
console.error(chalk.red(" ✗ Firebase CLI not found.\n"));
|
|
190
200
|
console.error(chalk.bold(" Install it with:\n"));
|
|
@@ -200,7 +210,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
200
210
|
|
|
201
211
|
console.log(chalk.bold("\n📋 Step 2/11 — Checking Firebase login...\n"));
|
|
202
212
|
|
|
203
|
-
const loginList = run("firebase login:list", { silent: true });
|
|
213
|
+
const loginList = run(["firebase", "login:list"], { silent: true });
|
|
204
214
|
const isLoggedIn = loginList && !loginList.includes("No authorized accounts");
|
|
205
215
|
|
|
206
216
|
if (!isLoggedIn) {
|
|
@@ -208,7 +218,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
208
218
|
chalk.yellow(" Not logged into Firebase. Opening login flow...\n"),
|
|
209
219
|
);
|
|
210
220
|
try {
|
|
211
|
-
|
|
221
|
+
execFileSync("firebase", ["login"], { stdio: "inherit" });
|
|
212
222
|
} catch {
|
|
213
223
|
console.error(chalk.red("\n Firebase login failed or was cancelled."));
|
|
214
224
|
console.error(
|
|
@@ -219,7 +229,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
// Re-check after login.
|
|
222
|
-
const loginListAfter = run("firebase login:list", { silent: true });
|
|
232
|
+
const loginListAfter = run(["firebase", "login:list"], { silent: true });
|
|
223
233
|
if (!loginListAfter || loginListAfter.includes("No authorized accounts")) {
|
|
224
234
|
console.error(chalk.red(" Firebase login required. Aborting.\n"));
|
|
225
235
|
process.exit(1);
|
|
@@ -275,7 +285,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
275
285
|
// Check if the project already exists.
|
|
276
286
|
let projectExists = false;
|
|
277
287
|
try {
|
|
278
|
-
|
|
288
|
+
execFileSync("firebase", ["apps:list", "--project", projectId], {
|
|
279
289
|
encoding: "utf-8",
|
|
280
290
|
stdio: "pipe",
|
|
281
291
|
});
|
|
@@ -291,7 +301,13 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
291
301
|
} else {
|
|
292
302
|
try {
|
|
293
303
|
runOrFail(
|
|
294
|
-
|
|
304
|
+
[
|
|
305
|
+
"firebase",
|
|
306
|
+
"projects:create",
|
|
307
|
+
projectId,
|
|
308
|
+
"--display-name",
|
|
309
|
+
"Forge Remote",
|
|
310
|
+
],
|
|
295
311
|
"Failed to create Firebase project.",
|
|
296
312
|
);
|
|
297
313
|
console.log(chalk.green(` ✓ Firebase project created: ${projectId}`));
|
|
@@ -333,7 +349,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
333
349
|
// First, check if a web app already exists for this project.
|
|
334
350
|
try {
|
|
335
351
|
const appsList = runOrFail(
|
|
336
|
-
|
|
352
|
+
["firebase", "apps:list", "--project", projectId],
|
|
337
353
|
"Failed to list apps.",
|
|
338
354
|
);
|
|
339
355
|
|
|
@@ -355,7 +371,14 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
355
371
|
// No existing web app — create one.
|
|
356
372
|
try {
|
|
357
373
|
const createAppOutput = runOrFail(
|
|
358
|
-
|
|
374
|
+
[
|
|
375
|
+
"firebase",
|
|
376
|
+
"apps:create",
|
|
377
|
+
"web",
|
|
378
|
+
"Forge Remote Mobile",
|
|
379
|
+
"--project",
|
|
380
|
+
projectId,
|
|
381
|
+
],
|
|
359
382
|
"Failed to create web app.",
|
|
360
383
|
);
|
|
361
384
|
|
|
@@ -373,7 +396,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
373
396
|
if (!appId) {
|
|
374
397
|
try {
|
|
375
398
|
const appsList = runOrFail(
|
|
376
|
-
|
|
399
|
+
["firebase", "apps:list", "--project", projectId],
|
|
377
400
|
"Failed to list apps.",
|
|
378
401
|
);
|
|
379
402
|
|
|
@@ -408,7 +431,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
408
431
|
|
|
409
432
|
try {
|
|
410
433
|
const sdkOutput = runOrFail(
|
|
411
|
-
|
|
434
|
+
["firebase", "apps:sdkconfig", "web", appId, "--project", projectId],
|
|
412
435
|
"Failed to get SDK config.",
|
|
413
436
|
);
|
|
414
437
|
|
|
@@ -462,7 +485,30 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
462
485
|
|
|
463
486
|
// Cache SDK config so `pair` can read it without the Firebase CLI.
|
|
464
487
|
const sdkCachePath = join(forgeDir, "sdk-config.json");
|
|
465
|
-
writeFileSync(sdkCachePath, JSON.stringify(sdkConfig, null, 2)
|
|
488
|
+
writeFileSync(sdkCachePath, JSON.stringify(sdkConfig, null, 2), {
|
|
489
|
+
mode: 0o600,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Remove browser-only restriction from API key so it works from mobile.
|
|
493
|
+
try {
|
|
494
|
+
const projectNumber = sdkConfig.messagingSenderId;
|
|
495
|
+
const removed = await removeBrowserKeyRestriction(
|
|
496
|
+
projectId,
|
|
497
|
+
projectNumber,
|
|
498
|
+
);
|
|
499
|
+
if (removed) {
|
|
500
|
+
console.log(chalk.green(" ✓ API key updated for mobile access"));
|
|
501
|
+
}
|
|
502
|
+
} catch (e) {
|
|
503
|
+
console.log(
|
|
504
|
+
chalk.yellow(` ⚠ Could not update API key restrictions: ${e.message}`),
|
|
505
|
+
);
|
|
506
|
+
console.log(
|
|
507
|
+
chalk.dim(
|
|
508
|
+
" You may need to remove browser restrictions manually in Google Cloud Console.",
|
|
509
|
+
),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
466
512
|
} catch (e) {
|
|
467
513
|
console.error(chalk.red(` ✗ ${e.message}`));
|
|
468
514
|
process.exit(1);
|
|
@@ -474,11 +520,11 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
474
520
|
|
|
475
521
|
// First check if Firestore already exists by listing databases.
|
|
476
522
|
const existingDbs = run(
|
|
477
|
-
|
|
523
|
+
["firebase", "firestore:databases:list", "--project", projectId],
|
|
478
524
|
{ silent: true },
|
|
479
525
|
);
|
|
480
526
|
|
|
481
|
-
if (existingDbs && existingDbs.includes("
|
|
527
|
+
if (existingDbs && existingDbs.includes("default")) {
|
|
482
528
|
console.log(chalk.yellow(" ⚠ Firestore already enabled — continuing."));
|
|
483
529
|
results.firestoreEnabled = true;
|
|
484
530
|
} else {
|
|
@@ -487,7 +533,14 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
487
533
|
for (let attempt = 0; attempt < maxFirestoreRetries; attempt++) {
|
|
488
534
|
try {
|
|
489
535
|
runOrFail(
|
|
490
|
-
|
|
536
|
+
[
|
|
537
|
+
"firebase",
|
|
538
|
+
"firestore:databases:create",
|
|
539
|
+
"(default)",
|
|
540
|
+
"--project",
|
|
541
|
+
projectId,
|
|
542
|
+
"--location=nam5",
|
|
543
|
+
],
|
|
491
544
|
"Failed to enable Firestore.",
|
|
492
545
|
);
|
|
493
546
|
console.log(
|
|
@@ -503,7 +556,8 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
503
556
|
if (
|
|
504
557
|
fullOutput.includes("already exists") ||
|
|
505
558
|
fullOutput.includes("ALREADY_EXISTS") ||
|
|
506
|
-
fullOutput.includes("database already exists")
|
|
559
|
+
fullOutput.includes("database already exists") ||
|
|
560
|
+
fullOutput.includes("409")
|
|
507
561
|
) {
|
|
508
562
|
console.log(
|
|
509
563
|
chalk.yellow(" ⚠ Firestore already enabled — continuing."),
|
|
@@ -853,7 +907,9 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
|
853
907
|
|
|
854
908
|
// Persist project ID and desktop ID so re-runs (even on different networks)
|
|
855
909
|
// always target the same Firebase project and desktop document.
|
|
856
|
-
writeFileSync(configPath, JSON.stringify({ projectId, desktopId }, null, 2)
|
|
910
|
+
writeFileSync(configPath, JSON.stringify({ projectId, desktopId }, null, 2), {
|
|
911
|
+
mode: 0o600,
|
|
912
|
+
});
|
|
857
913
|
console.log(
|
|
858
914
|
chalk.dim(` Config saved to ${configPath} (stable across networks)`),
|
|
859
915
|
);
|
package/src/project-scanner.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { readdirSync, statSync, existsSync } from "fs";
|
|
1
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "fs";
|
|
2
2
|
import { join, basename } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
+
import * as log from "./logger.js";
|
|
4
5
|
|
|
5
|
-
//
|
|
6
|
-
const
|
|
6
|
+
// Default directories to scan for projects.
|
|
7
|
+
const DEFAULT_SCAN_DIRS = [
|
|
7
8
|
join(homedir(), "Documents"),
|
|
8
9
|
join(homedir(), "Projects"),
|
|
9
10
|
join(homedir(), "Developer"),
|
|
@@ -15,6 +16,29 @@ const SCAN_DIRS = [
|
|
|
15
16
|
join(homedir(), "Desktop"),
|
|
16
17
|
];
|
|
17
18
|
|
|
19
|
+
// Maximum depth to recurse into each scan directory.
|
|
20
|
+
const MAX_DEPTH = 4;
|
|
21
|
+
|
|
22
|
+
// Directories to skip during recursive scanning.
|
|
23
|
+
const SKIP_DIRS = new Set([
|
|
24
|
+
"node_modules",
|
|
25
|
+
".git",
|
|
26
|
+
"__pycache__",
|
|
27
|
+
"target",
|
|
28
|
+
"build",
|
|
29
|
+
"dist",
|
|
30
|
+
".next",
|
|
31
|
+
".nuxt",
|
|
32
|
+
"vendor",
|
|
33
|
+
".dart_tool",
|
|
34
|
+
".pub-cache",
|
|
35
|
+
"Pods",
|
|
36
|
+
".gradle",
|
|
37
|
+
"venv",
|
|
38
|
+
".venv",
|
|
39
|
+
"env",
|
|
40
|
+
]);
|
|
41
|
+
|
|
18
42
|
// Files that indicate a directory is a project root.
|
|
19
43
|
const PROJECT_MARKERS = [
|
|
20
44
|
".git",
|
|
@@ -29,8 +53,34 @@ const PROJECT_MARKERS = [
|
|
|
29
53
|
"build.gradle",
|
|
30
54
|
"Makefile",
|
|
31
55
|
"CMakeLists.txt",
|
|
56
|
+
".xcodeproj", // Xcode projects (check for any child matching)
|
|
57
|
+
"Podfile",
|
|
58
|
+
"composer.json",
|
|
59
|
+
"mix.exs",
|
|
60
|
+
"stack.yaml",
|
|
61
|
+
"dune-project",
|
|
32
62
|
];
|
|
33
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Load user-configured extra scan paths from ~/.forge-remote/config.json.
|
|
66
|
+
* The config can contain a "scanPaths" array of absolute directory paths.
|
|
67
|
+
*/
|
|
68
|
+
function loadExtraScanPaths() {
|
|
69
|
+
try {
|
|
70
|
+
const configPath = join(homedir(), ".forge-remote", "config.json");
|
|
71
|
+
if (!existsSync(configPath)) return [];
|
|
72
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
73
|
+
if (Array.isArray(config.scanPaths)) {
|
|
74
|
+
return config.scanPaths.filter(
|
|
75
|
+
(p) => typeof p === "string" && p.startsWith("/"),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore config parse errors.
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
34
84
|
/**
|
|
35
85
|
* Scan common directories for projects.
|
|
36
86
|
* Returns an array of { path, name, lastOpened } objects.
|
|
@@ -39,64 +89,80 @@ export async function scanProjects() {
|
|
|
39
89
|
const projects = [];
|
|
40
90
|
const seen = new Set();
|
|
41
91
|
|
|
42
|
-
|
|
43
|
-
|
|
92
|
+
const extraPaths = loadExtraScanPaths();
|
|
93
|
+
const scanDirs = [...DEFAULT_SCAN_DIRS, ...extraPaths];
|
|
44
94
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const dirPath = join(scanDir, entry.name);
|
|
52
|
-
if (seen.has(dirPath)) continue;
|
|
53
|
-
|
|
54
|
-
if (isProject(dirPath)) {
|
|
55
|
-
seen.add(dirPath);
|
|
56
|
-
const stat = statSync(dirPath);
|
|
57
|
-
projects.push({
|
|
58
|
-
path: dirPath,
|
|
59
|
-
name: basename(dirPath),
|
|
60
|
-
lastOpened: stat.mtime.toISOString(),
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Also scan one level deeper for nested project dirs
|
|
65
|
-
// (e.g. ~/Documents/IronForgeApps/Mobile/ForgeRemote).
|
|
66
|
-
try {
|
|
67
|
-
const subEntries = readdirSync(dirPath, { withFileTypes: true });
|
|
68
|
-
for (const sub of subEntries) {
|
|
69
|
-
if (!sub.isDirectory() || sub.name.startsWith(".")) continue;
|
|
70
|
-
const subPath = join(dirPath, sub.name);
|
|
71
|
-
if (seen.has(subPath)) continue;
|
|
72
|
-
|
|
73
|
-
if (isProject(subPath)) {
|
|
74
|
-
seen.add(subPath);
|
|
75
|
-
const stat = statSync(subPath);
|
|
76
|
-
projects.push({
|
|
77
|
-
path: subPath,
|
|
78
|
-
name: basename(subPath),
|
|
79
|
-
lastOpened: stat.mtime.toISOString(),
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
84
|
-
// Skip subdirs we can't read.
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
// Skip dirs we can't read.
|
|
89
|
-
}
|
|
95
|
+
log.info(`Scanning ${scanDirs.length} directories for projects...`);
|
|
96
|
+
|
|
97
|
+
for (const scanDir of scanDirs) {
|
|
98
|
+
if (!existsSync(scanDir)) continue;
|
|
99
|
+
scanRecursive(scanDir, 0, projects, seen);
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
// Sort by lastOpened descending.
|
|
93
103
|
projects.sort((a, b) => b.lastOpened.localeCompare(a.lastOpened));
|
|
94
104
|
|
|
105
|
+
log.info(`Found ${projects.length} projects`);
|
|
95
106
|
return projects;
|
|
96
107
|
}
|
|
97
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Recursively scan a directory for projects up to MAX_DEPTH.
|
|
111
|
+
* When a project is found, it is added to the list and we do NOT recurse
|
|
112
|
+
* into it (a project root's subdirectories are not separate projects).
|
|
113
|
+
*/
|
|
114
|
+
function scanRecursive(dirPath, depth, projects, seen) {
|
|
115
|
+
if (depth > MAX_DEPTH) return;
|
|
116
|
+
|
|
117
|
+
let entries;
|
|
118
|
+
try {
|
|
119
|
+
entries = readdirSync(dirPath, { withFileTypes: true });
|
|
120
|
+
} catch {
|
|
121
|
+
return; // Permission denied or unreadable.
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (!entry.isDirectory()) continue;
|
|
126
|
+
const name = entry.name;
|
|
127
|
+
if (name.startsWith(".") && name !== ".git") continue;
|
|
128
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
129
|
+
|
|
130
|
+
const fullPath = join(dirPath, name);
|
|
131
|
+
if (seen.has(fullPath)) continue;
|
|
132
|
+
|
|
133
|
+
if (isProject(fullPath)) {
|
|
134
|
+
seen.add(fullPath);
|
|
135
|
+
try {
|
|
136
|
+
const stat = statSync(fullPath);
|
|
137
|
+
projects.push({
|
|
138
|
+
path: fullPath,
|
|
139
|
+
name: basename(fullPath),
|
|
140
|
+
lastOpened: stat.mtime.toISOString(),
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
// stat failed — skip.
|
|
144
|
+
}
|
|
145
|
+
// Don't recurse into project directories.
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Not a project — recurse deeper.
|
|
150
|
+
scanRecursive(fullPath, depth + 1, projects, seen);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
98
154
|
function isProject(dirPath) {
|
|
99
155
|
for (const marker of PROJECT_MARKERS) {
|
|
156
|
+
// For .xcodeproj, check if any child ends with .xcodeproj
|
|
157
|
+
if (marker === ".xcodeproj") {
|
|
158
|
+
try {
|
|
159
|
+
const children = readdirSync(dirPath);
|
|
160
|
+
if (children.some((c) => c.endsWith(".xcodeproj"))) return true;
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
100
166
|
if (existsSync(join(dirPath, marker))) return true;
|
|
101
167
|
}
|
|
102
168
|
return false;
|
package/src/session-manager.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Forge Remote Relay — Desktop Agent for Forge Remote
|
|
2
2
|
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
3
|
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
-
// AGPL-3.0 License — See LICENSE
|
|
5
4
|
|
|
6
5
|
import { getDb, FieldValue } from "./firebase.js";
|
|
6
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
7
9
|
import { spawn, execSync } from "child_process";
|
|
10
|
+
import { Socket } from "net";
|
|
8
11
|
import * as log from "./logger.js";
|
|
9
12
|
import { startTunnel, stopTunnel } from "./tunnel-manager.js";
|
|
10
13
|
import {
|
|
@@ -27,9 +30,52 @@ import {
|
|
|
27
30
|
* ANTHROPIC_API_KEY, etc.). Finally, strip any CLAUDECODE / CLAUDE_CODE_*
|
|
28
31
|
* vars that block nested launches.
|
|
29
32
|
*/
|
|
33
|
+
// Environment variables that are safe to pass to spawned Claude processes.
|
|
34
|
+
// Everything else is stripped to prevent leaking secrets to Firestore.
|
|
35
|
+
const ENV_ALLOWLIST = new Set([
|
|
36
|
+
// Essential for process execution
|
|
37
|
+
"PATH",
|
|
38
|
+
"HOME",
|
|
39
|
+
"USER",
|
|
40
|
+
"SHELL",
|
|
41
|
+
"TERM",
|
|
42
|
+
"LANG",
|
|
43
|
+
"LC_ALL",
|
|
44
|
+
"LC_CTYPE",
|
|
45
|
+
"TMPDIR",
|
|
46
|
+
"XDG_CONFIG_HOME",
|
|
47
|
+
"XDG_DATA_HOME",
|
|
48
|
+
"XDG_CACHE_HOME",
|
|
49
|
+
// Required for Claude / Anthropic
|
|
50
|
+
"ANTHROPIC_API_KEY",
|
|
51
|
+
"ANTHROPIC_BASE_URL",
|
|
52
|
+
// Node.js
|
|
53
|
+
"NODE_PATH",
|
|
54
|
+
"NODE_ENV",
|
|
55
|
+
"NODE_OPTIONS",
|
|
56
|
+
"NPM_CONFIG_PREFIX",
|
|
57
|
+
// Common dev tools
|
|
58
|
+
"EDITOR",
|
|
59
|
+
"VISUAL",
|
|
60
|
+
"PAGER",
|
|
61
|
+
"GIT_AUTHOR_NAME",
|
|
62
|
+
"GIT_AUTHOR_EMAIL",
|
|
63
|
+
"GIT_COMMITTER_NAME",
|
|
64
|
+
"GIT_COMMITTER_EMAIL",
|
|
65
|
+
// Platform-specific
|
|
66
|
+
"HOMEBREW_PREFIX",
|
|
67
|
+
"HOMEBREW_CELLAR",
|
|
68
|
+
// Encoding
|
|
69
|
+
"PYTHONIOENCODING",
|
|
70
|
+
"PYTHONUTF8",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// Prefixes that are always blocked (override allowlist).
|
|
74
|
+
const BLOCKED_PREFIXES = ["CLAUDECODE", "CLAUDE_CODE"];
|
|
75
|
+
|
|
30
76
|
function buildSpawnEnv() {
|
|
31
77
|
// Start with current runtime env — this has auth context.
|
|
32
|
-
const
|
|
78
|
+
const fullEnv = { ...process.env };
|
|
33
79
|
|
|
34
80
|
// Try to merge shell profile vars (picks up PATH changes, API keys, etc.).
|
|
35
81
|
try {
|
|
@@ -44,8 +90,8 @@ function buildSpawnEnv() {
|
|
|
44
90
|
if (idx > 0) {
|
|
45
91
|
const key = line.slice(0, idx);
|
|
46
92
|
// Only add vars that aren't already set — don't overwrite runtime env.
|
|
47
|
-
if (!(key in
|
|
48
|
-
|
|
93
|
+
if (!(key in fullEnv)) {
|
|
94
|
+
fullEnv[key] = line.slice(idx + 1);
|
|
49
95
|
}
|
|
50
96
|
}
|
|
51
97
|
}
|
|
@@ -53,12 +99,15 @@ function buildSpawnEnv() {
|
|
|
53
99
|
log.warn("Could not resolve user shell profile — using process.env only");
|
|
54
100
|
}
|
|
55
101
|
|
|
56
|
-
//
|
|
57
|
-
const
|
|
58
|
-
for (const key of Object.keys(
|
|
102
|
+
// Filter to allowlisted vars only, then remove any blocked prefixes.
|
|
103
|
+
const env = {};
|
|
104
|
+
for (const key of Object.keys(fullEnv)) {
|
|
59
105
|
if (BLOCKED_PREFIXES.some((prefix) => key.startsWith(prefix))) {
|
|
60
106
|
log.info(`Removing blocking env var: ${key}`);
|
|
61
|
-
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ENV_ALLOWLIST.has(key)) {
|
|
110
|
+
env[key] = fullEnv[key];
|
|
62
111
|
}
|
|
63
112
|
}
|
|
64
113
|
|
|
@@ -115,6 +164,30 @@ function getActiveSessionCount() {
|
|
|
115
164
|
return count;
|
|
116
165
|
}
|
|
117
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Rate limiting — prevents command spam / DoS
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
|
172
|
+
const RATE_LIMIT_MAX = 30; // max commands per window
|
|
173
|
+
const commandTimestamps = [];
|
|
174
|
+
|
|
175
|
+
function isRateLimited() {
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
// Remove timestamps older than the window.
|
|
178
|
+
while (
|
|
179
|
+
commandTimestamps.length > 0 &&
|
|
180
|
+
commandTimestamps[0] < now - RATE_LIMIT_WINDOW_MS
|
|
181
|
+
) {
|
|
182
|
+
commandTimestamps.shift();
|
|
183
|
+
}
|
|
184
|
+
if (commandTimestamps.length >= RATE_LIMIT_MAX) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
commandTimestamps.push(now);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
118
191
|
// ---------------------------------------------------------------------------
|
|
119
192
|
// Command listeners
|
|
120
193
|
// ---------------------------------------------------------------------------
|
|
@@ -149,6 +222,29 @@ export function listenForCommands(desktopId) {
|
|
|
149
222
|
// Desktop commands
|
|
150
223
|
// ---------------------------------------------------------------------------
|
|
151
224
|
|
|
225
|
+
// Allowlist of valid desktop command types.
|
|
226
|
+
const VALID_DESKTOP_COMMANDS = new Set([
|
|
227
|
+
"start_session",
|
|
228
|
+
"rescan_projects",
|
|
229
|
+
"list_directory",
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
// Sensitive directories that should never be browsed.
|
|
233
|
+
const BLOCKED_DIRS = [
|
|
234
|
+
"/proc",
|
|
235
|
+
"/sys",
|
|
236
|
+
"/dev",
|
|
237
|
+
"/etc",
|
|
238
|
+
// User secret directories (expanded from home)
|
|
239
|
+
path.join(homedir(), ".ssh"),
|
|
240
|
+
path.join(homedir(), ".gnupg"),
|
|
241
|
+
path.join(homedir(), ".aws"),
|
|
242
|
+
path.join(homedir(), ".forge-remote"),
|
|
243
|
+
path.join(homedir(), ".config/gcloud"),
|
|
244
|
+
path.join(homedir(), ".docker"),
|
|
245
|
+
path.join(homedir(), ".kube"),
|
|
246
|
+
];
|
|
247
|
+
|
|
152
248
|
async function handleDesktopCommand(desktopId, commandDoc) {
|
|
153
249
|
const data = commandDoc.data();
|
|
154
250
|
const db = getDb();
|
|
@@ -158,6 +254,26 @@ async function handleDesktopCommand(desktopId, commandDoc) {
|
|
|
158
254
|
.collection("commands")
|
|
159
255
|
.doc(commandDoc.id);
|
|
160
256
|
|
|
257
|
+
// Rate limiting — reject if too many commands per minute.
|
|
258
|
+
if (isRateLimited()) {
|
|
259
|
+
log.warn(`Rate limited: rejecting command ${data.type}`);
|
|
260
|
+
await cmdRef.update({
|
|
261
|
+
status: "failed",
|
|
262
|
+
error: "Rate limited. Try again in a moment.",
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Validate command type against allowlist.
|
|
268
|
+
if (!VALID_DESKTOP_COMMANDS.has(data.type)) {
|
|
269
|
+
log.warn(`Unknown desktop command type: ${data.type}`);
|
|
270
|
+
await cmdRef.update({
|
|
271
|
+
status: "failed",
|
|
272
|
+
error: `Unknown command type: ${data.type}`,
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
161
277
|
log.command(data.type, data.payload?.projectPath || "");
|
|
162
278
|
await cmdRef.update({ status: "processing" });
|
|
163
279
|
|
|
@@ -166,8 +282,41 @@ async function handleDesktopCommand(desktopId, commandDoc) {
|
|
|
166
282
|
case "start_session":
|
|
167
283
|
await startNewSession(desktopId, data.payload);
|
|
168
284
|
break;
|
|
169
|
-
|
|
170
|
-
|
|
285
|
+
case "rescan_projects": {
|
|
286
|
+
const { scanProjects } = await import("./project-scanner.js");
|
|
287
|
+
const { updateProjects } = await import("./desktop.js");
|
|
288
|
+
const projects = await scanProjects();
|
|
289
|
+
await updateProjects(desktopId, projects);
|
|
290
|
+
log.info(`Rescanned projects: found ${projects.length}`);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case "list_directory": {
|
|
294
|
+
const rawPath = data.payload?.path || homedir();
|
|
295
|
+
|
|
296
|
+
// Security: only allow absolute paths, resolve to real path to
|
|
297
|
+
// prevent symlink traversal, and block sensitive system dirs.
|
|
298
|
+
if (!path.isAbsolute(rawPath)) {
|
|
299
|
+
throw new Error("Path must be absolute");
|
|
300
|
+
}
|
|
301
|
+
const targetPath = await realpath(path.resolve(rawPath));
|
|
302
|
+
|
|
303
|
+
if (BLOCKED_DIRS.some((d) => targetPath.startsWith(d))) {
|
|
304
|
+
throw new Error("Access to this directory is not allowed");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
308
|
+
const dirs = entries
|
|
309
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
310
|
+
.map((e) => e.name)
|
|
311
|
+
.sort((a, b) =>
|
|
312
|
+
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
|
313
|
+
);
|
|
314
|
+
await cmdRef.update({
|
|
315
|
+
status: "completed",
|
|
316
|
+
result: { path: targetPath, dirs },
|
|
317
|
+
});
|
|
318
|
+
return; // Skip generic completed update below
|
|
319
|
+
}
|
|
171
320
|
}
|
|
172
321
|
await cmdRef.update({ status: "completed" });
|
|
173
322
|
} catch (e) {
|
|
@@ -198,6 +347,14 @@ function watchSessionCommands(sessionId) {
|
|
|
198
347
|
});
|
|
199
348
|
}
|
|
200
349
|
|
|
350
|
+
// Allowlist of valid session command types.
|
|
351
|
+
const VALID_SESSION_COMMANDS = new Set([
|
|
352
|
+
"send_prompt",
|
|
353
|
+
"stop_session",
|
|
354
|
+
"kill_session",
|
|
355
|
+
"retry_tunnel",
|
|
356
|
+
]);
|
|
357
|
+
|
|
201
358
|
async function handleSessionCommand(sessionId, commandDoc) {
|
|
202
359
|
const data = commandDoc.data();
|
|
203
360
|
const db = getDb();
|
|
@@ -207,6 +364,26 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
207
364
|
.collection("commands")
|
|
208
365
|
.doc(commandDoc.id);
|
|
209
366
|
|
|
367
|
+
// Rate limiting.
|
|
368
|
+
if (isRateLimited()) {
|
|
369
|
+
log.warn(`Rate limited: rejecting session command ${data.type}`);
|
|
370
|
+
await cmdRef.update({
|
|
371
|
+
status: "failed",
|
|
372
|
+
error: "Rate limited. Try again in a moment.",
|
|
373
|
+
});
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Validate command type.
|
|
378
|
+
if (!VALID_SESSION_COMMANDS.has(data.type)) {
|
|
379
|
+
log.warn(`Unknown session command type: ${data.type}`);
|
|
380
|
+
await cmdRef.update({
|
|
381
|
+
status: "failed",
|
|
382
|
+
error: `Unknown command type: ${data.type}`,
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
210
387
|
await cmdRef.update({ status: "processing" });
|
|
211
388
|
|
|
212
389
|
try {
|
|
@@ -230,8 +407,6 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
230
407
|
log.command("retry_tunnel", sessionId.slice(0, 8));
|
|
231
408
|
await retryTunnel(sessionId);
|
|
232
409
|
break;
|
|
233
|
-
default:
|
|
234
|
-
log.warn(`Unknown session command type: ${data.type}`);
|
|
235
410
|
}
|
|
236
411
|
await cmdRef.update({ status: "completed" });
|
|
237
412
|
} catch (e) {
|
|
@@ -282,6 +457,17 @@ export async function startNewSession(desktopId, payload) {
|
|
|
282
457
|
const db = getDb();
|
|
283
458
|
const resolvedModel = model || "sonnet";
|
|
284
459
|
const resolvedPath = projectPath || process.cwd();
|
|
460
|
+
|
|
461
|
+
// Security: validate that projectPath is absolute and exists.
|
|
462
|
+
if (!path.isAbsolute(resolvedPath)) {
|
|
463
|
+
throw new Error("projectPath must be an absolute path");
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
await realpath(resolvedPath);
|
|
467
|
+
} catch {
|
|
468
|
+
throw new Error(`projectPath does not exist: ${resolvedPath}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
285
471
|
const projectName = resolvedPath.split("/").pop();
|
|
286
472
|
|
|
287
473
|
// Create session document.
|
|
@@ -1195,21 +1381,85 @@ const LOCALHOST_PATTERNS = [
|
|
|
1195
1381
|
/(?:running|serving|available|dev server)\b.*?\bport\s+(\d{3,5})/i,
|
|
1196
1382
|
];
|
|
1197
1383
|
|
|
1384
|
+
// If any of these appear in the text, the port mention is likely an error — skip it.
|
|
1385
|
+
const ERROR_CONTEXT_PATTERNS = [
|
|
1386
|
+
/port.*(?:conflict|in use|already|busy|occupied|EADDRINUSE)/i,
|
|
1387
|
+
/(?:conflict|in use|already|busy|occupied|EADDRINUSE).*port/i,
|
|
1388
|
+
/(?:failed|error|couldn't|cannot|unable).*(?:start|bind|listen)/i,
|
|
1389
|
+
/(?:address|port).*already.*in.*use/i,
|
|
1390
|
+
/EADDRINUSE/i,
|
|
1391
|
+
];
|
|
1392
|
+
|
|
1198
1393
|
// Ports we've already tunneled for a session (avoid duplicates).
|
|
1199
1394
|
const tunneledPorts = new Map(); // sessionId → Set<port>
|
|
1200
1395
|
|
|
1396
|
+
/**
|
|
1397
|
+
* Quick check: is something actually listening on this port?
|
|
1398
|
+
* Tries IPv4 (127.0.0.1) first, then IPv6 (::1) — modern Node/Angular
|
|
1399
|
+
* dev servers often bind to IPv6 only.
|
|
1400
|
+
*/
|
|
1401
|
+
function checkPortServing(port) {
|
|
1402
|
+
return new Promise((resolve) => {
|
|
1403
|
+
const tryConnect = (host, fallback) => {
|
|
1404
|
+
const socket = new Socket();
|
|
1405
|
+
socket.setTimeout(1500);
|
|
1406
|
+
socket.once("connect", () => {
|
|
1407
|
+
socket.destroy();
|
|
1408
|
+
resolve(true);
|
|
1409
|
+
});
|
|
1410
|
+
socket.once("error", () => {
|
|
1411
|
+
if (fallback) {
|
|
1412
|
+
tryConnect(fallback, null);
|
|
1413
|
+
} else {
|
|
1414
|
+
resolve(false);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
socket.once("timeout", () => {
|
|
1418
|
+
socket.destroy();
|
|
1419
|
+
if (fallback) {
|
|
1420
|
+
tryConnect(fallback, null);
|
|
1421
|
+
} else {
|
|
1422
|
+
resolve(false);
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
socket.connect(port, host);
|
|
1426
|
+
};
|
|
1427
|
+
tryConnect("127.0.0.1", "::1");
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1201
1431
|
async function detectAndTunnelDevServer(sessionId, text) {
|
|
1202
1432
|
if (!text) return;
|
|
1203
1433
|
|
|
1434
|
+
// Skip if the text is about a port error (conflict, already in use, etc.).
|
|
1435
|
+
for (const errPattern of ERROR_CONTEXT_PATTERNS) {
|
|
1436
|
+
if (errPattern.test(text)) {
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1204
1441
|
for (const pattern of LOCALHOST_PATTERNS) {
|
|
1205
1442
|
const match = text.match(pattern);
|
|
1206
1443
|
if (match) {
|
|
1207
1444
|
const port = parseInt(match[1], 10);
|
|
1208
1445
|
if (port < 1024 || port > 65535) continue;
|
|
1209
1446
|
|
|
1210
|
-
// Skip if we already tunneled this port for this session.
|
|
1211
1447
|
const ported = tunneledPorts.get(sessionId) || new Set();
|
|
1448
|
+
|
|
1449
|
+
// Verify the port is actually serving before tunneling.
|
|
1450
|
+
const isServing = await checkPortServing(port);
|
|
1451
|
+
if (!isServing) {
|
|
1452
|
+
// Port mentioned but nothing listening — clear from set so a
|
|
1453
|
+
// future restart can re-trigger tunneling.
|
|
1454
|
+
ported.delete(port);
|
|
1455
|
+
tunneledPorts.set(sessionId, ported);
|
|
1456
|
+
log.info(`Port ${port} mentioned but not serving — skipping tunnel`);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Already tunneled and server still up — skip.
|
|
1212
1461
|
if (ported.has(port)) return;
|
|
1462
|
+
|
|
1213
1463
|
ported.add(port);
|
|
1214
1464
|
tunneledPorts.set(sessionId, ported);
|
|
1215
1465
|
|
package/src/tunnel-manager.js
CHANGED
|
@@ -140,7 +140,7 @@ function startHostProxy(targetPort) {
|
|
|
140
140
|
);
|
|
141
141
|
|
|
142
142
|
const opts = {
|
|
143
|
-
hostname: "
|
|
143
|
+
hostname: "localhost",
|
|
144
144
|
port: targetPort,
|
|
145
145
|
path: clientReq.url,
|
|
146
146
|
method: clientReq.method,
|
|
@@ -182,7 +182,7 @@ function startHostProxy(targetPort) {
|
|
|
182
182
|
|
|
183
183
|
// Handle WebSocket upgrades (Vite HMR needs this).
|
|
184
184
|
proxy.on("upgrade", (req, clientSocket, head) => {
|
|
185
|
-
const serverSocket = connect(targetPort, "
|
|
185
|
+
const serverSocket = connect(targetPort, "localhost", () => {
|
|
186
186
|
// Rebuild the upgrade request with scrubbed headers.
|
|
187
187
|
const headers = scrubHeaders(req.headers, targetPort);
|
|
188
188
|
let reqStr = `${req.method} ${req.url} HTTP/1.1\r\n`;
|