forge-remote 0.1.2 → 0.1.4

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.4",
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
 
@@ -403,14 +426,59 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
403
426
 
404
427
  console.log(chalk.green(` ✓ App ID: ${appId}`));
405
428
 
406
- // Get SDK config.
429
+ // Get SDK config (with retry — newly created apps may take a few seconds to propagate).
407
430
  let sdkConfig = {};
408
431
 
409
432
  try {
410
- const sdkOutput = runOrFail(
411
- `firebase apps:sdkconfig web ${appId} --project ${projectId}`,
412
- "Failed to get SDK config.",
413
- );
433
+ let sdkOutput = null;
434
+ const maxSdkRetries = 4;
435
+ const retryDelayMs = 5000; // 5 seconds between retries.
436
+
437
+ for (let attempt = 1; attempt <= maxSdkRetries; attempt++) {
438
+ try {
439
+ sdkOutput = runOrFail(
440
+ ["firebase", "apps:sdkconfig", "web", appId, "--project", projectId],
441
+ "Failed to get SDK config.",
442
+ );
443
+ break; // Success — exit retry loop.
444
+ } catch (e) {
445
+ if (attempt < maxSdkRetries) {
446
+ console.log(
447
+ chalk.yellow(
448
+ ` ⚠ SDK config not available yet (attempt ${attempt}/${maxSdkRetries}). ` +
449
+ `Retrying in ${retryDelayMs / 1000}s...`,
450
+ ),
451
+ );
452
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
453
+ } else {
454
+ // All retries exhausted — throw with helpful instructions.
455
+ const fallbackErr = new Error(
456
+ `Could not retrieve SDK config after ${maxSdkRetries} attempts.\n\n` +
457
+ chalk.bold(
458
+ " This usually means the Firebase web app hasn't fully propagated yet.\n",
459
+ ) +
460
+ chalk.bold(" Try these steps:\n\n") +
461
+ chalk.cyan(
462
+ ` 1. Wait 30 seconds, then re-run: forge-remote init\n`,
463
+ ) +
464
+ chalk.cyan(` 2. Or run manually:\n`) +
465
+ chalk.dim(
466
+ ` firebase apps:sdkconfig web ${appId} --project ${projectId}\n`,
467
+ ) +
468
+ chalk.dim(
469
+ ` Then copy the config into: ~/.forge-remote/sdk-config.json\n`,
470
+ ) +
471
+ chalk.dim(
472
+ ` Format: { "apiKey": "...", "authDomain": "...", "projectId": "...",\n`,
473
+ ) +
474
+ chalk.dim(
475
+ ` "storageBucket": "...", "messagingSenderId": "...", "appId": "..." }\n`,
476
+ ),
477
+ );
478
+ throw fallbackErr;
479
+ }
480
+ }
481
+ }
414
482
 
415
483
  // Parse the config — output may be JSON or JS object.
416
484
  const jsonMatch = sdkOutput.match(/\{[\s\S]*\}/);
@@ -462,7 +530,30 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
462
530
 
463
531
  // Cache SDK config so `pair` can read it without the Firebase CLI.
464
532
  const sdkCachePath = join(forgeDir, "sdk-config.json");
465
- writeFileSync(sdkCachePath, JSON.stringify(sdkConfig, null, 2));
533
+ writeFileSync(sdkCachePath, JSON.stringify(sdkConfig, null, 2), {
534
+ mode: 0o600,
535
+ });
536
+
537
+ // Remove browser-only restriction from API key so it works from mobile.
538
+ try {
539
+ const projectNumber = sdkConfig.messagingSenderId;
540
+ const removed = await removeBrowserKeyRestriction(
541
+ projectId,
542
+ projectNumber,
543
+ );
544
+ if (removed) {
545
+ console.log(chalk.green(" ✓ API key updated for mobile access"));
546
+ }
547
+ } catch (e) {
548
+ console.log(
549
+ chalk.yellow(` ⚠ Could not update API key restrictions: ${e.message}`),
550
+ );
551
+ console.log(
552
+ chalk.dim(
553
+ " You may need to remove browser restrictions manually in Google Cloud Console.",
554
+ ),
555
+ );
556
+ }
466
557
  } catch (e) {
467
558
  console.error(chalk.red(` ✗ ${e.message}`));
468
559
  process.exit(1);
@@ -474,11 +565,11 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
474
565
 
475
566
  // First check if Firestore already exists by listing databases.
476
567
  const existingDbs = run(
477
- `firebase firestore:databases:list --project ${projectId}`,
568
+ ["firebase", "firestore:databases:list", "--project", projectId],
478
569
  { silent: true },
479
570
  );
480
571
 
481
- if (existingDbs && existingDbs.includes("(default)")) {
572
+ if (existingDbs && existingDbs.includes("default")) {
482
573
  console.log(chalk.yellow(" ⚠ Firestore already enabled — continuing."));
483
574
  results.firestoreEnabled = true;
484
575
  } else {
@@ -487,7 +578,14 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
487
578
  for (let attempt = 0; attempt < maxFirestoreRetries; attempt++) {
488
579
  try {
489
580
  runOrFail(
490
- `firebase firestore:databases:create default --project ${projectId} --location=nam5`,
581
+ [
582
+ "firebase",
583
+ "firestore:databases:create",
584
+ "(default)",
585
+ "--project",
586
+ projectId,
587
+ "--location=nam5",
588
+ ],
491
589
  "Failed to enable Firestore.",
492
590
  );
493
591
  console.log(
@@ -503,7 +601,8 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
503
601
  if (
504
602
  fullOutput.includes("already exists") ||
505
603
  fullOutput.includes("ALREADY_EXISTS") ||
506
- fullOutput.includes("database already exists")
604
+ fullOutput.includes("database already exists") ||
605
+ fullOutput.includes("409")
507
606
  ) {
508
607
  console.log(
509
608
  chalk.yellow(" ⚠ Firestore already enabled — continuing."),
@@ -853,7 +952,9 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
853
952
 
854
953
  // Persist project ID and desktop ID so re-runs (even on different networks)
855
954
  // always target the same Firebase project and desktop document.
856
- writeFileSync(configPath, JSON.stringify({ projectId, desktopId }, null, 2));
955
+ writeFileSync(configPath, JSON.stringify({ projectId, desktopId }, null, 2), {
956
+ mode: 0o600,
957
+ });
857
958
  console.log(
858
959
  chalk.dim(` Config saved to ${configPath} (stable across networks)`),
859
960
  );
@@ -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,16 +254,74 @@ 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
 
164
280
  try {
165
281
  switch (data.type) {
166
- case "start_session":
167
- await startNewSession(desktopId, data.payload);
282
+ case "start_session": {
283
+ const sessionId = await startNewSession(desktopId, data.payload);
284
+ await cmdRef.update({
285
+ status: "completed",
286
+ result: { sessionId },
287
+ });
288
+ return; // Skip generic completed update below
289
+ }
290
+ case "rescan_projects": {
291
+ const { scanProjects } = await import("./project-scanner.js");
292
+ const { updateProjects } = await import("./desktop.js");
293
+ const projects = await scanProjects();
294
+ await updateProjects(desktopId, projects);
295
+ log.info(`Rescanned projects: found ${projects.length}`);
168
296
  break;
169
- default:
170
- log.warn(`Unknown desktop command type: ${data.type}`);
297
+ }
298
+ case "list_directory": {
299
+ const rawPath = data.payload?.path || homedir();
300
+
301
+ // Security: only allow absolute paths, resolve to real path to
302
+ // prevent symlink traversal, and block sensitive system dirs.
303
+ if (!path.isAbsolute(rawPath)) {
304
+ throw new Error("Path must be absolute");
305
+ }
306
+ const targetPath = await realpath(path.resolve(rawPath));
307
+
308
+ if (BLOCKED_DIRS.some((d) => targetPath.startsWith(d))) {
309
+ throw new Error("Access to this directory is not allowed");
310
+ }
311
+
312
+ const entries = await readdir(targetPath, { withFileTypes: true });
313
+ const dirs = entries
314
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
315
+ .map((e) => e.name)
316
+ .sort((a, b) =>
317
+ a.localeCompare(b, undefined, { sensitivity: "base" }),
318
+ );
319
+ await cmdRef.update({
320
+ status: "completed",
321
+ result: { path: targetPath, dirs },
322
+ });
323
+ return; // Skip generic completed update below
324
+ }
171
325
  }
172
326
  await cmdRef.update({ status: "completed" });
173
327
  } catch (e) {
@@ -198,6 +352,14 @@ function watchSessionCommands(sessionId) {
198
352
  });
199
353
  }
200
354
 
355
+ // Allowlist of valid session command types.
356
+ const VALID_SESSION_COMMANDS = new Set([
357
+ "send_prompt",
358
+ "stop_session",
359
+ "kill_session",
360
+ "retry_tunnel",
361
+ ]);
362
+
201
363
  async function handleSessionCommand(sessionId, commandDoc) {
202
364
  const data = commandDoc.data();
203
365
  const db = getDb();
@@ -207,6 +369,26 @@ async function handleSessionCommand(sessionId, commandDoc) {
207
369
  .collection("commands")
208
370
  .doc(commandDoc.id);
209
371
 
372
+ // Rate limiting.
373
+ if (isRateLimited()) {
374
+ log.warn(`Rate limited: rejecting session command ${data.type}`);
375
+ await cmdRef.update({
376
+ status: "failed",
377
+ error: "Rate limited. Try again in a moment.",
378
+ });
379
+ return;
380
+ }
381
+
382
+ // Validate command type.
383
+ if (!VALID_SESSION_COMMANDS.has(data.type)) {
384
+ log.warn(`Unknown session command type: ${data.type}`);
385
+ await cmdRef.update({
386
+ status: "failed",
387
+ error: `Unknown command type: ${data.type}`,
388
+ });
389
+ return;
390
+ }
391
+
210
392
  await cmdRef.update({ status: "processing" });
211
393
 
212
394
  try {
@@ -230,8 +412,6 @@ async function handleSessionCommand(sessionId, commandDoc) {
230
412
  log.command("retry_tunnel", sessionId.slice(0, 8));
231
413
  await retryTunnel(sessionId);
232
414
  break;
233
- default:
234
- log.warn(`Unknown session command type: ${data.type}`);
235
415
  }
236
416
  await cmdRef.update({ status: "completed" });
237
417
  } catch (e) {
@@ -282,6 +462,17 @@ export async function startNewSession(desktopId, payload) {
282
462
  const db = getDb();
283
463
  const resolvedModel = model || "sonnet";
284
464
  const resolvedPath = projectPath || process.cwd();
465
+
466
+ // Security: validate that projectPath is absolute and exists.
467
+ if (!path.isAbsolute(resolvedPath)) {
468
+ throw new Error("projectPath must be an absolute path");
469
+ }
470
+ try {
471
+ await realpath(resolvedPath);
472
+ } catch {
473
+ throw new Error(`projectPath does not exist: ${resolvedPath}`);
474
+ }
475
+
285
476
  const projectName = resolvedPath.split("/").pop();
286
477
 
287
478
  // Create session document.
@@ -1195,21 +1386,85 @@ const LOCALHOST_PATTERNS = [
1195
1386
  /(?:running|serving|available|dev server)\b.*?\bport\s+(\d{3,5})/i,
1196
1387
  ];
1197
1388
 
1389
+ // If any of these appear in the text, the port mention is likely an error — skip it.
1390
+ const ERROR_CONTEXT_PATTERNS = [
1391
+ /port.*(?:conflict|in use|already|busy|occupied|EADDRINUSE)/i,
1392
+ /(?:conflict|in use|already|busy|occupied|EADDRINUSE).*port/i,
1393
+ /(?:failed|error|couldn't|cannot|unable).*(?:start|bind|listen)/i,
1394
+ /(?:address|port).*already.*in.*use/i,
1395
+ /EADDRINUSE/i,
1396
+ ];
1397
+
1198
1398
  // Ports we've already tunneled for a session (avoid duplicates).
1199
1399
  const tunneledPorts = new Map(); // sessionId → Set<port>
1200
1400
 
1401
+ /**
1402
+ * Quick check: is something actually listening on this port?
1403
+ * Tries IPv4 (127.0.0.1) first, then IPv6 (::1) — modern Node/Angular
1404
+ * dev servers often bind to IPv6 only.
1405
+ */
1406
+ function checkPortServing(port) {
1407
+ return new Promise((resolve) => {
1408
+ const tryConnect = (host, fallback) => {
1409
+ const socket = new Socket();
1410
+ socket.setTimeout(1500);
1411
+ socket.once("connect", () => {
1412
+ socket.destroy();
1413
+ resolve(true);
1414
+ });
1415
+ socket.once("error", () => {
1416
+ if (fallback) {
1417
+ tryConnect(fallback, null);
1418
+ } else {
1419
+ resolve(false);
1420
+ }
1421
+ });
1422
+ socket.once("timeout", () => {
1423
+ socket.destroy();
1424
+ if (fallback) {
1425
+ tryConnect(fallback, null);
1426
+ } else {
1427
+ resolve(false);
1428
+ }
1429
+ });
1430
+ socket.connect(port, host);
1431
+ };
1432
+ tryConnect("127.0.0.1", "::1");
1433
+ });
1434
+ }
1435
+
1201
1436
  async function detectAndTunnelDevServer(sessionId, text) {
1202
1437
  if (!text) return;
1203
1438
 
1439
+ // Skip if the text is about a port error (conflict, already in use, etc.).
1440
+ for (const errPattern of ERROR_CONTEXT_PATTERNS) {
1441
+ if (errPattern.test(text)) {
1442
+ return;
1443
+ }
1444
+ }
1445
+
1204
1446
  for (const pattern of LOCALHOST_PATTERNS) {
1205
1447
  const match = text.match(pattern);
1206
1448
  if (match) {
1207
1449
  const port = parseInt(match[1], 10);
1208
1450
  if (port < 1024 || port > 65535) continue;
1209
1451
 
1210
- // Skip if we already tunneled this port for this session.
1211
1452
  const ported = tunneledPorts.get(sessionId) || new Set();
1453
+
1454
+ // Verify the port is actually serving before tunneling.
1455
+ const isServing = await checkPortServing(port);
1456
+ if (!isServing) {
1457
+ // Port mentioned but nothing listening — clear from set so a
1458
+ // future restart can re-trigger tunneling.
1459
+ ported.delete(port);
1460
+ tunneledPorts.set(sessionId, ported);
1461
+ log.info(`Port ${port} mentioned but not serving — skipping tunnel`);
1462
+ return;
1463
+ }
1464
+
1465
+ // Already tunneled and server still up — skip.
1212
1466
  if (ported.has(port)) return;
1467
+
1213
1468
  ported.add(port);
1214
1469
  tunneledPorts.set(sessionId, ported);
1215
1470
 
@@ -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`;