Files
fusion/packages/engine/src/remote-access/provider-adapters.ts
Fusion 87d9c1c380 feat(FN-2925): merge fusion/fn-2925
- Mobile header row polish: finalize QuickChatFAB CSS for mobile viewport handling, apply visual viewport height to quick chat panel, remove duplicate keyboard overlap subtraction
- Add `node-routing-policy.ts` with routing logic for mesh network nodes; expand scheduler node-routing tests with new policy coverage
- Improve reviewer decision logic and add reviewer test cases for changed behavior
- Refactor triage module (48 lines changed) with updated agent selection logic
- Update `AgentsView` component and styles; add `AgentsView.test.tsx` integration test
- Simplify `QuickChatFAB.test.tsx` test by removing CSS class permutation coverage
- Update architecture docs and settings reference to reflect new routing capabilities

Commits merged:
- fix(FN-2925): remove duplicate keyboard overlap subtraction
- feat(FN-2925): complete Step 5 — finalize mobile header row polish
- fix(FN-2925): apply visual viewport height to mobile quick chat panel
- feat(FN-2903): merge fusion/fn-2903
- feat(FN-2950): merge fusion/fn-2950

Files changed:
docs/architecture.md                               |  19 +--
 docs/settings-reference.md                         |   2 +-
 packages/dashboard/app/components/AgentsView.css   |  20 +++-
 packages/dashboard/app/components/AgentsView.tsx   |  11 +-
 packages/dashboard/app/components/QuickChatFAB.css |  32 ++++-
 .../dashboard/app/components/SettingsModal.tsx     |   4 +-
 .../app/components/__tests__/AgentsView.test.tsx   |  37 ++++++
 .../app/components/__tests__/QuickChatFAB.test.tsx |  18 +--
 .../src/__tests__/node-routing-policy.test.ts      |  78 ++++++++++++
 packages/engine/src/__tests__/reviewer.test.ts     |  19 ++-
 .../src/__tests__/scheduler-node-routing.test.ts   | 131 ++++++++++++++++++++-
 packages/engine/src/__tests__/triage.test.ts       |  16 +--
 packages/engine/src/node-routing-policy.ts         |  45 +++++++
 packages/engine/src/reviewer.ts                    |  33 ++++--
 packages/engine/src/scheduler.ts                   |  44 ++++++-
 packages/engine/src/triage.ts                      |  48 ++++----
 16 files changed, 473 insertions(+), 84 deletions(-)

Fusion-Task-Id: FN-2925
2026-04-29 12:53:10 -07:00

205 lines
6.0 KiB
TypeScript

import { accessSync, constants as fsConstants } from "node:fs";
import type {
PreparedTunnelCommand,
TunnelOutputStream,
TunnelProvider,
TunnelProviderAdapter,
TunnelProviderConfig,
} from "./types.js";
const DEFAULT_READINESS_TIMEOUT_MS = 20_000;
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
const URL_PATTERN = /(https?:\/\/[^\s]+)/i;
function isAbsoluteOrPathLike(input: string): boolean {
return input.startsWith("/") || input.startsWith("./") || input.startsWith("../");
}
function assertNonEmpty(value: unknown, label: string): asserts value is string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`invalid_config:${label} must be a non-empty string`);
}
}
function ensureNumberInRange(value: number | undefined, label: string): number | undefined {
if (value === undefined) {
return undefined;
}
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`invalid_config:${label} must be a positive number`);
}
return Math.floor(value);
}
function collectSensitiveValues(config: TunnelProviderConfig): string[] {
const values = new Set<string>();
const env = config.env ?? {};
const tokenEnvVar = config.tokenEnvVar;
if (typeof tokenEnvVar === "string" && tokenEnvVar.trim().length > 0) {
const tokenValue = env[tokenEnvVar] ?? process.env[tokenEnvVar];
if (!tokenValue) {
throw new Error(`invalid_config:missing credential in env var ${tokenEnvVar}`);
}
values.add(tokenValue);
}
for (const envName of config.sensitiveEnvVars ?? []) {
const envValue = env[envName] ?? process.env[envName];
if (envValue) {
values.add(envValue);
}
}
return [...values].sort((a, b) => b.length - a.length);
}
function redactValue(input: string, sensitiveValues: string[]): string {
let redacted = input;
for (const value of sensitiveValues) {
redacted = redacted.split(value).join("[REDACTED]");
}
return redacted;
}
function redactArgs(args: string[], sensitiveValues: string[]): string[] {
return args.map((arg) => redactValue(arg, sensitiveValues));
}
function buildCommand(config: TunnelProviderConfig): PreparedTunnelCommand {
const command = config.executablePath.trim();
const args = [...config.args];
const env: NodeJS.ProcessEnv = {
...process.env,
...(config.env ?? {}),
};
const sensitiveValues = collectSensitiveValues(config);
const redactedPreview = [command, ...redactArgs(args, sensitiveValues)].join(" ").trim();
return {
provider: config.provider,
command,
args,
cwd: config.cwd,
env,
redactedPreview,
sensitiveValues,
readinessTimeoutMs: config.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS,
stopTimeoutMs: config.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS,
};
}
function parseCommonReadiness(line: string): { ready: boolean; url?: string } | null {
const normalized = line.trim();
if (!normalized) {
return null;
}
const urlMatch = normalized.match(URL_PATTERN);
const url = urlMatch?.[1];
if (/\b(connected|ready|available|started|serving)\b/i.test(normalized)) {
return { ready: true, url };
}
if (url && /\b(trycloudflare|tailscale|ts\.net|serve|funnel)\b/i.test(normalized)) {
return { ready: true, url };
}
return null;
}
function validateBaseConfig(config: TunnelProviderConfig, provider: TunnelProvider): void {
if (config.provider !== provider) {
throw new Error(`invalid_config:config provider ${config.provider} does not match ${provider}`);
}
assertNonEmpty(config.executablePath, "executablePath");
if (!Array.isArray(config.args)) {
throw new Error("invalid_config:args must be an array");
}
for (const [index, arg] of config.args.entries()) {
assertNonEmpty(arg, `args[${index}]`);
}
if (config.cwd !== undefined) {
assertNonEmpty(config.cwd, "cwd");
}
if (config.tokenEnvVar !== undefined) {
assertNonEmpty(config.tokenEnvVar, "tokenEnvVar");
}
ensureNumberInRange(config.readinessTimeoutMs, "readinessTimeoutMs");
ensureNumberInRange(config.stopTimeoutMs, "stopTimeoutMs");
}
const tailscaleAdapter: TunnelProviderAdapter = {
provider: "tailscale",
validateConfig(config) {
validateBaseConfig(config, "tailscale");
},
buildCommand(config) {
this.validateConfig(config);
return buildCommand(config);
},
parseReadiness(line: string, _stream: TunnelOutputStream) {
// tailscale funnel prints "Available on the internet:" first (no URL),
// then a follow-up line with https://<host>.ts.net/. Don't signal ready
// until we have the URL itself, otherwise the manager freezes status
// before the URL line arrives.
const normalized = line.trim();
if (!normalized) return null;
const urlMatch = normalized.match(URL_PATTERN);
const url = urlMatch?.[1];
if (!url) return null;
if (/\bts\.net\b/i.test(url) || /\b(tailscale|funnel|serve)\b/i.test(normalized)) {
return { ready: true, url };
}
return null;
},
};
const cloudflareAdapter: TunnelProviderAdapter = {
provider: "cloudflare",
validateConfig(config) {
validateBaseConfig(config, "cloudflare");
if (config.provider === "cloudflare" && config.quickTunnel === true) {
return;
}
if ("credentialsPath" in config && config.credentialsPath !== undefined) {
assertNonEmpty(config.credentialsPath, "credentialsPath");
if (isAbsoluteOrPathLike(config.credentialsPath)) {
accessSync(config.credentialsPath, fsConstants.R_OK);
}
}
},
buildCommand(config) {
this.validateConfig(config);
return buildCommand(config);
},
parseReadiness(line: string, _stream: TunnelOutputStream) {
return parseCommonReadiness(line);
},
};
const ADAPTERS: Record<TunnelProvider, TunnelProviderAdapter> = {
tailscale: tailscaleAdapter,
cloudflare: cloudflareAdapter,
};
export function getTunnelProviderAdapter(provider: TunnelProvider): TunnelProviderAdapter {
return ADAPTERS[provider];
}
export function redactTunnelText(input: string, sensitiveValues: string[]): string {
return redactValue(input, sensitiveValues);
}