n8n/packages/@n8n/computer-use/src/config.ts

383 lines
13 KiB
TypeScript

/* eslint-disable id-denylist */
import * as os from 'node:os';
import * as path from 'node:path';
import yargsParser from 'yargs-parser';
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Permission options — keys derive the ToolGroup union type
// Defaults match the Recommended template from the spec.
// ---------------------------------------------------------------------------
export const TOOL_GROUP_DEFINITIONS = {
filesystemRead: {
envVar: 'PERMISSION_FILESYSTEM_READ',
cliFlag: 'permission-filesystem-read',
default: 'allow',
description: 'Filesystem read access mode: deny | ask | allow',
},
filesystemWrite: {
envVar: 'PERMISSION_FILESYSTEM_WRITE',
cliFlag: 'permission-filesystem-write',
default: 'ask',
description: 'Filesystem write access mode: deny | ask | allow',
},
shell: {
envVar: 'PERMISSION_SHELL',
cliFlag: 'permission-shell',
default: 'deny',
description: 'Shell execution mode: deny | ask | allow',
},
computer: {
envVar: 'PERMISSION_COMPUTER',
cliFlag: 'permission-computer',
default: 'deny',
description: 'Computer control (screenshot, mouse/keyboard) mode: deny | ask | allow',
},
browser: {
envVar: 'PERMISSION_BROWSER',
cliFlag: 'permission-browser',
default: 'ask',
description: 'Browser automation mode: deny | ask | allow',
},
} as const;
export type ToolGroup = keyof typeof TOOL_GROUP_DEFINITIONS;
export const PERMISSION_MODES = ['deny', 'ask', 'allow'] as const;
export const permissionModeSchema = z.enum(PERMISSION_MODES);
export type PermissionMode = z.infer<typeof permissionModeSchema>;
// ---------------------------------------------------------------------------
// Unified config type — the single type passed to daemon, client, settings
// ---------------------------------------------------------------------------
export interface GatewayConfig {
logLevel: 'silent' | 'error' | 'warn' | 'info' | 'debug';
port: number;
allowedOrigins: string[];
filesystem: { dir: string };
computer: { shell: { timeout: number } };
browser: {
defaultBrowser: string;
};
/** Startup permission overrides (ENV/CLI). Merged with persistent settings in SettingsStore. */
permissions: Partial<Record<ToolGroup, PermissionMode>>;
/** Where resource access confirmation prompts are displayed. */
permissionConfirmation: 'client' | 'instance';
}
// ---------------------------------------------------------------------------
// Environment variable helpers
// ---------------------------------------------------------------------------
const ENV_PREFIX = 'N8N_GATEWAY_';
function envString(name: string): string | undefined {
return process.env[`${ENV_PREFIX}${name}`];
}
function envBoolean(name: string): boolean | undefined {
const raw = envString(name);
if (raw === undefined) return undefined;
return raw === 'true' || raw === '1';
}
function envNumber(name: string): number | undefined {
const raw = envString(name);
if (raw === undefined) return undefined;
const n = Number(raw);
return Number.isNaN(n) ? undefined : n;
}
// ---------------------------------------------------------------------------
// Zod schemas (internal — used only in parseConfig)
// ---------------------------------------------------------------------------
export const logLevelSchema = z.enum(['silent', 'error', 'warn', 'info', 'debug']).default('info');
export type LogLevel = z.infer<typeof logLevelSchema>;
export const portSchema = z.number().int().positive().default(7655);
const structuralConfigSchema = z.object({
logLevel: logLevelSchema,
port: portSchema,
allowedOrigins: z.array(z.string()).default([]),
filesystem: z.object({ dir: z.string().default('.') }).default({}),
computer: z
.object({
shell: z.object({ timeout: z.number().int().positive().default(30_000) }).default({}),
})
.default({}),
browser: z
.object({
defaultBrowser: z.string().default('chrome'),
})
.default({}),
permissionConfirmation: z.enum(['client', 'instance']).default('instance'),
});
// ---------------------------------------------------------------------------
// Read permission overrides from ENV and CLI
// ---------------------------------------------------------------------------
function readPermissionOverridesFromEnv(): Partial<Record<ToolGroup, PermissionMode>> {
const overrides: Partial<Record<ToolGroup, PermissionMode>> = {};
for (const [group, option] of Object.entries(TOOL_GROUP_DEFINITIONS) as Array<
[ToolGroup, (typeof TOOL_GROUP_DEFINITIONS)[ToolGroup]]
>) {
const raw = envString(option.envVar);
if (raw !== undefined) {
const result = permissionModeSchema.safeParse(raw);
if (result.success) overrides[group] = result.data;
}
}
return overrides;
}
function readPermissionOverridesFromCli(
args: yargsParser.Arguments,
): Partial<Record<ToolGroup, PermissionMode>> {
const overrides: Partial<Record<ToolGroup, PermissionMode>> = {};
for (const [group, option] of Object.entries(TOOL_GROUP_DEFINITIONS) as Array<
[ToolGroup, (typeof TOOL_GROUP_DEFINITIONS)[ToolGroup]]
>) {
const cliKey = option.cliFlag.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
const raw = args[cliKey] as string | undefined;
if (raw !== undefined) {
const result = permissionModeSchema.safeParse(raw);
if (result.success) overrides[group] = result.data;
}
}
return overrides;
}
// ---------------------------------------------------------------------------
// Config builder — merges env vars and CLI flags into a partial structural config
// ---------------------------------------------------------------------------
type PartialStructural = z.input<typeof structuralConfigSchema>;
function buildEnvConfig(): PartialStructural {
const config: Record<string, unknown> = {};
const logLevel = envString('LOG_LEVEL') ?? process.env.LOG_LEVEL;
if (logLevel) config.logLevel = logLevel;
const allowedOrigins = envString('ALLOWED_ORIGINS');
if (allowedOrigins) {
config.allowedOrigins = allowedOrigins
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
const fsDir = envString('FILESYSTEM_DIR');
if (fsDir) config.filesystem = { dir: fsDir };
const shellTimeout = envNumber('COMPUTER_SHELL_TIMEOUT');
if (shellTimeout !== undefined) config.computer = { shell: { timeout: shellTimeout } };
const defaultBrowser = envString('BROWSER_DEFAULT');
if (defaultBrowser) config.browser = { defaultBrowser };
const permissionConfirmation = envString('PERMISSION_CONFIRMATION');
if (permissionConfirmation) config.permissionConfirmation = permissionConfirmation;
return config as PartialStructural;
}
function buildCliConfig(args: yargsParser.Arguments): PartialStructural {
const config: Record<string, unknown> = {};
if (args['log-level']) config.logLevel = args['log-level'];
if (args.port !== undefined) config.port = args.port;
if (args['allow-origin']) {
const raw = args['allow-origin'] as unknown;
config.allowedOrigins = Array.isArray(raw) ? raw.map(String) : [String(raw)];
}
const dir = args['filesystem-dir'] as string;
if (dir) config.filesystem = { dir };
const timeout = args['computer-shell-timeout'] as number;
if (timeout !== undefined) config.computer = { shell: { timeout } };
if (args['browser-default'])
config.browser = { defaultBrowser: args['browser-default'] as string };
if (args['permission-confirmation'])
config.permissionConfirmation = args['permission-confirmation'];
return config as PartialStructural;
}
// ---------------------------------------------------------------------------
// Deep merge — merges CLI config over env config (CLI wins)
// ---------------------------------------------------------------------------
function deepMerge(
base: Record<string, unknown>,
override: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...base };
for (const key of Object.keys(override)) {
const baseVal = base[key];
const overrideVal = override[key];
if (
typeof baseVal === 'object' &&
baseVal !== null &&
typeof overrideVal === 'object' &&
overrideVal !== null &&
!Array.isArray(baseVal) &&
!Array.isArray(overrideVal)
) {
result[key] = deepMerge(
baseVal as Record<string, unknown>,
overrideVal as Record<string, unknown>,
);
} else {
result[key] = overrideVal;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Settings file path
// ---------------------------------------------------------------------------
export function getSettingsFilePath(): string {
return path.join(os.homedir(), '.n8n-gateway', 'settings.json');
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface ParsedArgs {
/** Subcommand: 'serve' or undefined (direct mode) */
command?: 'serve';
/** n8n instance URL (direct mode) */
url?: string;
/** Gateway API key (direct mode) */
apiKey?: string;
/** Complete resolved config, ready to pass to startDaemon / GatewayClient */
config: GatewayConfig;
/**
* When true, all permission prompts are auto-granted as "allow once".
* CLI-only — handle in cli.ts by passing confirmResourceAccess: () => 'allowOnce'.
*/
autoConfirm: boolean;
/**
* When true, skip all interactive prompts (startup config + resource access).
* Resource access falls back to denyOnce, or allowOnce when autoConfirm is also set.
*/
nonInteractive: boolean;
}
export function parseConfig(argv = process.argv.slice(2)): ParsedArgs {
const isServe = argv[0] === 'serve';
const rawArgs = isServe ? argv.slice(1) : argv;
const permissionFlags = Object.values(TOOL_GROUP_DEFINITIONS).map((o) => o.cliFlag);
const args = yargsParser(rawArgs, {
string: [
'log-level',
'filesystem-dir',
'browser-default',
'allow-origin',
'permission-confirmation',
...permissionFlags,
],
boolean: ['auto-confirm', 'non-interactive', 'help'],
number: ['port', 'computer-shell-timeout'],
alias: { h: 'help', p: 'port' },
});
// Three-tier merge: Zod defaults ← env ← CLI
const envConfig = buildEnvConfig();
const cliConfig = buildCliConfig(args);
const merged = deepMerge(
envConfig as Record<string, unknown>,
cliConfig as Record<string, unknown>,
);
// Handle positional args
let url: string | undefined;
let apiKey: string | undefined;
if (isServe) {
const positional = args._;
if (positional.length > 0 && typeof positional[0] === 'string') {
const dir = String(positional[0]);
if (!merged.filesystem || typeof merged.filesystem !== 'object') {
merged.filesystem = { dir };
} else if (!(merged.filesystem as Record<string, unknown>).dir) {
(merged.filesystem as Record<string, unknown>).dir = dir;
}
}
} else {
const positional = args._;
if (positional.length >= 2) {
url = String(positional[0]);
apiKey = String(positional[1]);
if (positional.length >= 3) {
const dir = String(positional[2]);
if (!merged.filesystem || typeof merged.filesystem !== 'object') {
merged.filesystem = { dir };
} else if (!(merged.filesystem as Record<string, unknown>).dir) {
(merged.filesystem as Record<string, unknown>).dir = dir;
}
}
} else if (!args.help) {
url = args.url as string | undefined;
apiKey = args['api-key'] as string | undefined;
if (args.dir) {
if (!merged.filesystem || typeof merged.filesystem !== 'object') {
merged.filesystem = { dir: args.dir as string };
}
}
}
}
// Resolve dir to absolute path (pre-parse, for explicitly provided values)
if (merged.filesystem && typeof merged.filesystem === 'object') {
const fs = merged.filesystem as Record<string, unknown>;
if (typeof fs.dir === 'string') {
fs.dir = path.resolve(fs.dir);
}
}
const structural = structuralConfigSchema.parse(merged);
// Resolve dir to absolute path (post-parse, for Zod defaults like '.')
structural.filesystem.dir = path.resolve(structural.filesystem.dir);
if (url) url = url.replace(/\/$/, '');
// Collect permission overrides from ENV and CLI (not persisted to settings file)
const envPermissions = readPermissionOverridesFromEnv();
const cliPermissions = readPermissionOverridesFromCli(args);
const permissions: Partial<Record<ToolGroup, PermissionMode>> = {
...envPermissions,
...cliPermissions, // CLI wins over ENV
};
const autoConfirm =
(args['auto-confirm'] as boolean | undefined) ?? envBoolean('AUTO_CONFIRM') ?? false;
const nonInteractive =
(args['non-interactive'] as boolean | undefined) ?? envBoolean('NON_INTERACTIVE') ?? false;
const config: GatewayConfig = { ...structural, permissions };
return {
command: isServe ? 'serve' : undefined,
url,
apiKey,
config,
autoConfirm,
nonInteractive,
};
}