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 CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.2",
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": "AGPL-3.0",
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 { execSync } from "child_process";
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 = execSync(
73
- `firebase apps:sdkconfig web --project ${projectId}`,
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;
@@ -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 { execSync } from "child_process";
7
6
  import {
@@ -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 response = await fetch(
94
- `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/config?updateMask=signIn.anonymous.enabled`,
95
- {
96
- method: "PATCH",
97
- headers: {
98
- Authorization: `Bearer ${token}`,
99
- "Content-Type": "application/json",
100
- "X-Goog-User-Project": projectId,
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
- body: JSON.stringify({
103
- signIn: {
104
- anonymous: {
105
- enabled: true,
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
- * Two-step process:
350
+ * Three-step process:
256
351
  * 1. Create a new ruleset containing the rules source.
257
- * 2. Update (or create) the `cloud.firestore` release to point to it.
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: Update existing release, or create if it doesn't exist yet
297
- const releaseName = `projects/${projectId}/releases/cloud.firestore`;
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
- let releaseResponse = await fetch(
306
- `https://firebaserules.googleapis.com/v1/${releaseName}`,
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(releaseBody),
403
+ body: JSON.stringify({
404
+ name: releaseName,
405
+ rulesetName: rulesetName,
406
+ }),
322
407
  },
323
408
  );
324
- }
325
409
 
326
- if (!releaseResponse.ok) {
327
- const err = await releaseResponse.json().catch(() => ({}));
328
- throw new Error(
329
- `Failed to deploy rules release: ${err.error?.message || releaseResponse.statusText}`,
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 and return stdout (trimmed).
34
- * Returns null if the command fails.
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(cmd, { silent = false } = {}) {
37
+ function run(args, { silent = false } = {}) {
37
38
  try {
38
- return execSync(cmd, {
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(cmd, errorMsg) {
53
+ function runOrFail(args, errorMsg) {
51
54
  try {
52
- return execSync(cmd, {
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() : e.message;
58
- throw new Error(`${errorMsg}\n Command: ${cmd}\n ${stderr}`);
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") execSync(`open "${url}"`, { stdio: "pipe" });
117
- else if (p === "win32") execSync(`start "" "${url}"`, { stdio: "pipe" });
118
- else execSync(`xdg-open "${url}"`, { stdio: "pipe" });
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
- execSync("firebase login", { stdio: "inherit" });
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
- execSync(`firebase apps:list --project ${projectId}`, {
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
- `firebase projects:create ${projectId} --display-name "Forge Remote"`,
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
- `firebase apps:list --project ${projectId}`,
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
- `firebase apps:create web "Forge Remote Mobile" --project ${projectId}`,
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
- `firebase apps:list --project ${projectId}`,
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
- `firebase apps:sdkconfig web ${appId} --project ${projectId}`,
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
- `firebase firestore:databases:list --project ${projectId}`,
523
+ ["firebase", "firestore:databases:list", "--project", projectId],
478
524
  { silent: true },
479
525
  );
480
526
 
481
- if (existingDbs && existingDbs.includes("(default)")) {
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
- `firebase firestore:databases:create default --project ${projectId} --location=nam5`,
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
  );
@@ -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
- // Directories to scan (one level deep) for projects.
6
- const SCAN_DIRS = [
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
- for (const scanDir of SCAN_DIRS) {
43
- if (!existsSync(scanDir)) continue;
92
+ const extraPaths = loadExtraScanPaths();
93
+ const scanDirs = [...DEFAULT_SCAN_DIRS, ...extraPaths];
44
94
 
45
- try {
46
- const entries = readdirSync(scanDir, { withFileTypes: true });
47
- for (const entry of entries) {
48
- if (!entry.isDirectory()) continue;
49
- if (entry.name.startsWith(".")) continue;
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;
@@ -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 env = { ...process.env };
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 env)) {
48
- env[key] = line.slice(idx + 1);
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
- // Remove ALL env vars that block Claude from spawning.
57
- const BLOCKED_PREFIXES = ["CLAUDECODE", "CLAUDE_CODE"];
58
- for (const key of Object.keys(env)) {
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
- delete env[key];
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
- default:
170
- log.warn(`Unknown desktop command type: ${data.type}`);
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
 
@@ -140,7 +140,7 @@ function startHostProxy(targetPort) {
140
140
  );
141
141
 
142
142
  const opts = {
143
- hostname: "127.0.0.1",
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, "127.0.0.1", () => {
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`;