feat: Implement session based permission modes in Computer Use (#28184)

This commit is contained in:
Dimitri Lavrenük 2026-04-09 13:54:54 +02:00 committed by GitHub
parent 6f722efef3
commit d3e6519730
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1501 additions and 353 deletions

View file

@ -25,7 +25,8 @@
".": "./dist/cli.js",
"./daemon": "./dist/daemon.js",
"./config": "./dist/config.js",
"./logger": "./dist/logger.js"
"./logger": "./dist/logger.js",
"./gateway-session": "./dist/gateway-session.js"
},
"module": "src/cli.ts",
"types": "dist/cli.d.ts",

View file

@ -122,35 +122,51 @@ session modes are supported:
## Connection Flow
### 1. Capability Preview & Configuration
### 1. Silent startup
Before the Local Gateway initiates a connection to the n8n instance, the user
is shown a list of capabilities that the local machine supports. Capabilities
that are not available on the machine (e.g. computer control on a headless
server) are indicated as unavailable.
The app starts silently. If no settings file exists, one is created
automatically using the **Recommended** template (`filesystemDir` left empty).
No prompts are shown.
The user can enable or disable each capability individually. This gives the
user explicit control over what the AI is permitted to do on their machine
for this connection.
### 2. Connect command
The user must confirm the capability selection before the connection proceeds.
The user runs the connect command (URL + token provided via CLI arguments or
served automatically via daemon mode).
### 2. Establishing a Connection
### 3. Confirmation prompt
After the user confirms, the Local Gateway connects to the n8n instance and
registers the selected capabilities. The AI Agent is immediately aware of
which tools are available and can use them in subsequent conversations.
The app shows a single confirmation prompt displaying:
### 3. Active Connection
- The URL being connected to
- The current permission table (one row per tool group)
- The working directory
The user is offered three choices: **Yes**, **Edit permissions / directory**,
or **No**.
### 4. Optional edit
If the user chooses **Edit**, they can adjust per-group permission modes and/or
the working directory for this session. These edits are **session-local** — they
are not written back to the settings file.
### 5. Session established
The session is established with the confirmed settings. The AI is immediately
aware of which tools are available and can use them in subsequent conversations.
### 6. Active connection
While connected:
- The user can see that their Local Gateway is active.
- The AI can invoke any of the registered capabilities as needed during a
conversation.
- Session rules (`allowForSession`) accumulate in memory for the duration of
the connection.
- The connection persists across page reloads.
### 4. Disconnection
### 7. Disconnection
The user can explicitly disconnect the Local Gateway at any time. After
disconnection, the AI no longer has access to any local capabilities. If the
@ -279,53 +295,23 @@ only when the session ends.
---
## Startup Configuration
## Default Configuration
### Permission Setup
On first run the settings file is created silently at
`~/.n8n-gateway/settings.json` using the **Recommended** template
(`filesystemDir` left empty). No user interaction is required.
Before the gateway connects, the user must configure the permission mode for
each tool group. The gateway will not start unless at least one tool group is
enabled (`Ask` or `Allow`).
On subsequent runs the stored permissions and directory are loaded as
**defaults** for the next connection confirmation prompt. The user can override
them at connect time for the current session — these overrides are
**not** written back to the settings file.
**CLI** — An interactive prompt lists each tool group with its current mode.
If a valid configuration already exists the user can confirm it with `y` or
edit individual modes before proceeding.
**Native application** — The user sees an equivalent configuration UI.
### Filesystem Root Directory
When any filesystem tool group (Filesystem Access or Filesystem Write Access)
is enabled, the user must specify a root directory. The AI can only access
paths within this directory — all operations on paths outside are rejected.
This applies to both read and write operations.
### Configuration Templates
To simplify first-time setup, three templates are available. When no
configuration file exists the user selects a template before editing
individual modes.
| Template | Filesystem Access | Filesystem Write Access | Shell Execution | Computer Control | Browser Automation |
|---|---|---|---|---|---|
| **Recommended** (default) | Allow | Ask | Deny | Deny | Ask |
| **Yolo** | Allow | Allow | Allow | Allow | Allow |
| **Custom** | User-defined | User-defined | User-defined | User-defined | User-defined |
Regardless of template, the filesystem root directory must always be provided
when any filesystem capability is enabled.
### Configuration File
The gateway configuration is stored in a file managed by the Local Gateway
application. Whether the configuration persists across restarts depends on
whether the process has OS-level write access to that file — this is
independent of the permission model for tools. If write access is unavailable
the configuration is active only for the lifetime of the current process.
The settings file is only updated when the user chooses **Always allow** or
**Always deny** for a resource-level rule during an active session. Tool group
permission modes and the working directory are not persisted at connect time.
The configuration file stores:
- Permission mode per tool group
- Filesystem root directory (required when any filesystem capability is
enabled)
- Permission mode per tool group (used as defaults for the next connect prompt)
- Filesystem root directory (used as default for the next connect prompt)
- Permanently stored resource-level rules (`always allow` / `always deny`)

View file

@ -16,6 +16,7 @@
6. [Tool Call Dispatch](#6-tool-call-dispatch)
7. [Disconnect & Reconnect](#7-disconnect--reconnect)
8. [Module Settings](#8-module-settings)
9. [Session Model](#9-session-model)
---
@ -425,3 +426,74 @@ Per-user gateway state is delivered via two mechanisms:
- **Initial load**`GET /gateway/status` (called on page mount).
- **Live updates** — targeted push notification `instanceAiGatewayStateChanged`
sent only to the affected user via `push.sendToUsers(..., [userId])`.
---
## 9. Session Model
Tool group permission modes and the working directory are **session-scoped**
they live in a `GatewaySession` object created at connect time and discarded
when the session ends.
### GatewaySession
`GatewaySession` is constructed from defaults (loaded from the settings file
and merged with any CLI/ENV overrides) and a reference to `SettingsStore` for
persistent rule delegation.
```typescript
class GatewaySession {
constructor(
defaults: { permissions: Record<ToolGroup, PermissionMode>; dir: string },
settingsStore: SettingsStore,
)
// Mutable session settings — set by the confirmConnect prompt
setPermissions(p: Record<ToolGroup, PermissionMode>): void
setDir(dir: string): void
// Read session settings
get dir(): string
getAllPermissions(): Record<ToolGroup, PermissionMode>
getGroupMode(toolGroup: ToolGroup): PermissionMode // includes filesystemRead→Write constraint
// Permission check — used by GatewayClient for every tool call
check(toolGroup: ToolGroup, resource: string): PermissionMode
// Session-scoped allow rules — cleared on disconnect
allowForSession(toolGroup: ToolGroup, resource: string): void
clearSessionRules(): void
// Persistent rules — delegate to SettingsStore
alwaysAllow(toolGroup: ToolGroup, resource: string): void
alwaysDeny(toolGroup: ToolGroup, resource: string): void
// Flush pending writes on shutdown
flush(): Promise<void>
}
```
### Permission check evaluation order
`check(toolGroup, resource)` evaluates rules in this order:
1. Persistent deny list → `'deny'` (absolute priority, even in Allow mode)
2. Persistent allow list → `'allow'`
3. Session allow set → `'allow'`
4. Group mode → result of `getGroupMode()` (includes cross-group constraints)
### Persistent rules
`alwaysAllow` / `alwaysDeny` resource rules still write through to the settings
file via `SettingsStore` so they persist across sessions.
### DaemonOptions.confirmConnect signature
```typescript
confirmConnect: (url: string, session: GatewaySession) => Promise<boolean> | boolean
```
The session is pre-seeded from defaults and passed to the `confirmConnect`
callback. The callback may mutate the session (e.g. via `setPermissions` /
`setDir`) before returning `true`. The daemon then uses the mutated session
for the duration of the connection.

View file

@ -1,12 +1,13 @@
#!/usr/bin/env node
import { confirm } from '@inquirer/prompts';
import { select } from '@inquirer/prompts';
import * as fs from 'node:fs/promises';
import { parseConfig } from './config';
import { cliConfirmResourceAccess, sanitizeForTerminal } from './confirm-resource-cli';
import { startDaemon } from './daemon';
import { GatewayClient } from './gateway-client';
import { GatewaySession } from './gateway-session';
import {
configure,
logger,
@ -16,21 +17,59 @@ import {
printToolList,
} from './logger';
import { SettingsStore } from './settings-store';
import { applyTemplate, runStartupConfigCli } from './startup-config-cli';
import {
editPermissions,
ensureSettingsFile,
isAllDeny,
printPermissionsTable,
promptFilesystemDir,
} from './startup-config-cli';
import type { ConfirmResourceAccess } from './tools/types';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
async function cliConfirmConnect(url: string): Promise<boolean> {
return await confirm({ message: `Allow connection to ${sanitizeForTerminal(url)}?` });
async function cliConfirmConnect(url: string, session: GatewaySession): Promise<boolean> {
console.log(`\n Connecting to ${sanitizeForTerminal(url)}\n`);
printPermissionsTable(session.getAllPermissions());
console.log(` Working directory: ${session.dir}\n`);
const choice = await select({
message: 'Allow connection?',
choices: [
{ name: 'Yes', value: 'yes' },
{ name: 'Edit permissions / directory', value: 'edit' },
{ name: 'No', value: 'no' },
],
});
if (choice === 'no') return false;
if (choice === 'edit') {
let permissions = session.getAllPermissions();
do {
permissions = await editPermissions(permissions);
if (isAllDeny(permissions)) {
console.log('\n At least one capability must be Ask or Allow.\n');
}
} while (isAllDeny(permissions));
const filesystemActive =
permissions.filesystemRead !== 'deny' || permissions.filesystemWrite !== 'deny';
const dir = filesystemActive ? await promptFilesystemDir(session.dir) : session.dir;
session.setPermissions(permissions);
session.setDir(dir);
}
return true;
}
function makeConfirmConnect(
nonInteractive: boolean,
autoConfirm: boolean,
): (url: string) => Promise<boolean> | boolean {
): (url: string, session: GatewaySession) => Promise<boolean> | boolean {
if (autoConfirm) return () => true;
if (nonInteractive) return () => false;
return cliConfirmConnect;
@ -64,13 +103,9 @@ async function tryServe(): Promise<boolean> {
configure({ level: parsed.config.logLevel });
printBanner();
// Non-interactive: apply recommended template as explicit defaults (shell/computer stay deny
// unless overridden via --permission-* flags), then skip all interactive prompts.
const config = parsed.nonInteractive
? applyTemplate(parsed.config, 'default')
: await runStartupConfigCli(parsed.config);
await ensureSettingsFile(parsed.config);
startDaemon(config, {
startDaemon(parsed.config, {
confirmConnect: makeConfirmConnect(parsed.nonInteractive, parsed.autoConfirm),
confirmResourceAccess: makeConfirmResourceAccess(parsed.nonInteractive, parsed.autoConfirm),
});
@ -151,12 +186,22 @@ async function main(): Promise<void> {
process.exit(1);
}
const config = parsed.nonInteractive
? applyTemplate(parsed.config, 'default')
: await runStartupConfigCli(parsed.config);
await ensureSettingsFile(parsed.config);
// Validate filesystem directory exists
const dir = config.filesystem.dir;
const settingsStore = await SettingsStore.create();
const defaults = settingsStore.getDefaults(parsed.config);
const session = new GatewaySession(defaults, settingsStore);
const confirmConnect = makeConfirmConnect(parsed.nonInteractive, parsed.autoConfirm);
const approved = await confirmConnect(parsed.url, session);
if (!approved) {
logger.info('Connection rejected');
process.exit(0);
}
// Validate the directory — re-check for non-interactive mode (already validated
// interactively in cliConfirmConnect when running in interactive mode).
const dir = session.dir;
try {
const stat = await fs.stat(dir);
if (!stat.isDirectory()) {
@ -168,21 +213,24 @@ async function main(): Promise<void> {
process.exit(1);
}
printModuleStatus(config);
const settingsStore = await SettingsStore.create(config);
// printModuleStatus expects a GatewayConfig shape — derive one from the session.
printModuleStatus({
...parsed.config,
permissions: session.getAllPermissions(),
filesystem: { dir: session.dir },
});
const client = new GatewayClient({
url: parsed.url,
apiKey: parsed.apiKey,
config,
settingsStore,
config: parsed.config,
session,
confirmResourceAccess: makeConfirmResourceAccess(parsed.nonInteractive, parsed.autoConfirm),
});
const shutdown = () => {
logger.info('Shutting down');
void Promise.all([client.disconnect(), settingsStore.flush()]).finally(() => {
void Promise.all([client.disconnect(), session.flush()]).finally(() => {
process.exit(0);
});
};

View file

@ -0,0 +1,422 @@
/**
* Integration-style tests for the daemon HTTP server.
*
* The daemon uses module-level singletons (state, settingsStore, daemonOptions).
* Each test starts a fresh server on a random port and closes it afterwards.
* jest.resetModules() is used between suites to clear singleton state.
*/
import * as fs from 'node:fs/promises';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
// ---------------------------------------------------------------------------
// Module-level mocks — must be declared before imports
// ---------------------------------------------------------------------------
jest.mock('node:os', () => {
const actual = jest.requireActual<typeof os>('node:os');
return { ...actual, homedir: jest.fn(() => actual.homedir()) };
});
// Prevent GatewayClient.start() from making real network calls
jest.mock('./gateway-client', () => ({
['GatewayClient']: jest.fn().mockImplementation(() => ({
start: jest.fn().mockResolvedValue(undefined),
disconnect: jest.fn().mockResolvedValue(undefined),
tools: [],
})),
}));
// Suppress logger noise during tests
jest.mock('./logger', () => ({
logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() },
configure: jest.fn(),
printBanner: jest.fn(),
printConnected: jest.fn(),
printDisconnected: jest.fn(),
printListening: jest.fn(),
printShuttingDown: jest.fn(),
printToolList: jest.fn(),
printWaiting: jest.fn(),
printReconnecting: jest.fn(),
printAuthFailure: jest.fn(),
printReinitializing: jest.fn(),
printReinitFailed: jest.fn(),
}));
import type { GatewayConfig } from './config';
import type { DaemonOptions } from './daemon';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseJson<T>(raw: string): T {
try {
return JSON.parse(raw) as T;
} catch {
throw new Error(`Failed to parse JSON: ${raw}`);
}
}
const BASE_CONFIG: GatewayConfig = {
logLevel: 'silent',
port: 0, // bind to OS-assigned port
allowedOrigins: [],
filesystem: { dir: '/' },
computer: { shell: { timeout: 30_000 } },
browser: { defaultBrowser: 'chrome' },
permissions: {},
permissionConfirmation: 'instance',
};
type JsonBody = Record<string, unknown>;
async function post(
port: number,
urlPath: string,
body: JsonBody = {},
): Promise<{ status: number; body: JsonBody }> {
return await new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: urlPath,
method: 'POST',
headers: {
['Content-Type']: 'application/json',
['Content-Length']: Buffer.byteLength(payload),
},
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body: parseJson<JsonBody>(Buffer.concat(chunks).toString()),
}),
);
},
);
req.on('error', reject);
req.end(payload);
});
}
async function get(port: number, urlPath: string): Promise<{ status: number; body: JsonBody }> {
return await new Promise((resolve, reject) => {
http
.get({ hostname: '127.0.0.1', port, path: urlPath }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body: parseJson<JsonBody>(Buffer.concat(chunks).toString()),
}),
);
})
.on('error', reject);
});
}
async function listenPort(server: http.Server): Promise<number> {
return await new Promise((resolve) => {
server.once('listening', () => {
const addr = server.address();
resolve(typeof addr === 'object' && addr !== null ? addr.port : 0);
});
});
}
// ---------------------------------------------------------------------------
// Per-test setup: isolated tmpDir + settings file + fresh module instance
// ---------------------------------------------------------------------------
let tmpDir: string;
beforeEach(async () => {
jest.resetModules();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'daemon-test-'));
(os.homedir as jest.Mock).mockReturnValue(tmpDir);
// Write a minimal valid settings file so SettingsStore loads cleanly
const dir = path.join(tmpDir, '.n8n-gateway');
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'settings.json'),
JSON.stringify({ permissions: {}, resourcePermissions: {} }),
'utf-8',
);
});
afterEach(async () => {
jest.restoreAllMocks();
await fs.rm(tmpDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Helper to start a daemon for a single test
// ---------------------------------------------------------------------------
async function startTestDaemon(
configOverride: Partial<GatewayConfig> = {},
optionsOverride: Partial<DaemonOptions> = {},
): Promise<{ port: number; server: http.Server; close: () => Promise<void> }> {
// Re-require after resetModules so each test gets a clean singleton
const { startDaemon } = await import('./daemon');
const config: GatewayConfig = { ...BASE_CONFIG, ...configOverride };
const options: DaemonOptions = {
managedMode: true, // prevent SIGINT/SIGTERM handlers
confirmConnect: jest.fn().mockResolvedValue(true),
confirmResourceAccess: jest.fn().mockReturnValue('denyOnce'),
...optionsOverride,
};
const server = startDaemon(config, options);
const port = await listenPort(server);
const close = async () => await new Promise<void>((resolve) => server.close(() => resolve()));
return { port, server, close };
}
// ---------------------------------------------------------------------------
// GET /health
// ---------------------------------------------------------------------------
describe('GET /health', () => {
it('returns status ok and connected: false when no client is connected', async () => {
const { port, close } = await startTestDaemon();
try {
const res = await get(port, '/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.connected).toBe(false);
} finally {
await close();
}
});
});
// ---------------------------------------------------------------------------
// POST /connect — validation
// ---------------------------------------------------------------------------
describe('POST /connect — validation', () => {
it('returns 400 for missing url and token', async () => {
const { port, close } = await startTestDaemon();
try {
const res = await post(port, '/connect', {});
expect(res.status).toBe(400);
} finally {
await close();
}
});
it('returns 400 for invalid JSON body', async () => {
const { port, close } = await startTestDaemon();
try {
const status = await new Promise<number>((resolve, reject) => {
const payload = 'not-json';
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: '/connect',
method: 'POST',
headers: {
['Content-Type']: 'application/json',
['Content-Length']: Buffer.byteLength(payload),
},
},
(r) => {
r.resume();
resolve(r.statusCode ?? 0);
},
);
req.on('error', reject);
req.end(payload);
});
expect(status).toBe(400);
} finally {
await close();
}
});
it('returns 400 for an invalid URL', async () => {
const { port, close } = await startTestDaemon();
try {
const res = await post(port, '/connect', { url: 'not-a-url', token: 'tok' });
expect(res.status).toBe(400);
} finally {
await close();
}
});
});
// ---------------------------------------------------------------------------
// POST /connect — confirmConnect integration
// ---------------------------------------------------------------------------
describe('POST /connect — confirmConnect', () => {
it('calls confirmConnect with (url, session) and rejects with 403 when it returns false', async () => {
const confirmConnect = jest.fn().mockResolvedValue(false);
const { port, close } = await startTestDaemon({}, { confirmConnect });
try {
const res = await post(port, '/connect', {
url: 'http://localhost:5678',
token: 'tok',
});
expect(res.status).toBe(403);
const [calledUrl, calledSession] = confirmConnect.mock.calls[0] as [string, { dir: string }];
expect(calledUrl).toBe('http://localhost:5678');
expect(typeof calledSession.dir).toBe('string');
} finally {
await close();
}
});
it('skips confirmConnect for URLs in allowedOrigins', async () => {
const confirmConnect = jest.fn().mockResolvedValue(true);
const { port, close } = await startTestDaemon(
{ allowedOrigins: ['http://localhost:5678'], filesystem: { dir: tmpDir } },
{ confirmConnect },
);
try {
await post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
expect(confirmConnect).not.toHaveBeenCalled();
} finally {
await close();
}
});
it('returns 400 when the session dir is invalid after confirmation', async () => {
const { port, close } = await startTestDaemon(
{ filesystem: { dir: '/nonexistent-dir-xyz' } },
{ confirmConnect: jest.fn().mockResolvedValue(true) },
);
try {
const res = await post(port, '/connect', {
url: 'http://localhost:5678',
token: 'tok',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid directory/);
} finally {
await close();
}
});
});
// ---------------------------------------------------------------------------
// POST /connect — already connected
// ---------------------------------------------------------------------------
describe('POST /connect — already connected', () => {
it('returns 409 if a client is already connected', async () => {
const { port, close } = await startTestDaemon(
{ filesystem: { dir: tmpDir } },
{ confirmConnect: jest.fn().mockResolvedValue(true) },
);
try {
// First connection
await post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
// Second connection attempt
const res = await post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
expect(res.status).toBe(409);
} finally {
await close();
}
});
});
// ---------------------------------------------------------------------------
// POST /disconnect
// ---------------------------------------------------------------------------
describe('POST /disconnect', () => {
it('returns 200 when not connected', async () => {
const { port, close } = await startTestDaemon();
try {
const res = await post(port, '/disconnect');
expect(res.status).toBe(200);
expect(res.body.status).toBe('disconnected');
} finally {
await close();
}
});
it('disconnects an active client and resets state', async () => {
const { port, close } = await startTestDaemon(
{ filesystem: { dir: tmpDir } },
{ confirmConnect: jest.fn().mockResolvedValue(true) },
);
try {
await post(port, '/connect', { url: 'http://localhost:5678', token: 'tok' });
const healthBefore = await get(port, '/health');
expect(healthBefore.body.connected).toBe(true);
await post(port, '/disconnect');
const healthAfter = await get(port, '/health');
expect(healthAfter.body.connected).toBe(false);
} finally {
await close();
}
});
});
// ---------------------------------------------------------------------------
// GET /status
// ---------------------------------------------------------------------------
describe('GET /status', () => {
it('returns connected: false before any connection', async () => {
const { port, close } = await startTestDaemon();
try {
const res = await get(port, '/status');
expect(res.status).toBe(200);
expect(res.body.connected).toBe(false);
expect(res.body.connectedAt).toBeNull();
} finally {
await close();
}
});
});
// ---------------------------------------------------------------------------
// CORS preflight
// ---------------------------------------------------------------------------
describe('OPTIONS preflight', () => {
it('returns 204 with CORS headers', async () => {
const { port, close } = await startTestDaemon();
try {
const { status, headers } = await new Promise<{
status: number;
headers: Record<string, string | string[] | undefined>;
}>((resolve, reject) => {
const req = http.request(
{ hostname: '127.0.0.1', port, path: '/connect', method: 'OPTIONS' },
(r) => {
r.resume();
resolve({
status: r.statusCode ?? 0,
headers: r.headers as Record<string, string | string[] | undefined>,
});
},
);
req.on('error', reject);
req.end();
});
expect(status).toBe(204);
expect(headers['access-control-allow-origin']).toBe('*');
} finally {
await close();
}
});
});

View file

@ -1,13 +1,14 @@
import * as fs from 'node:fs/promises';
import * as http from 'node:http';
import type { GatewayConfig } from './config';
import { GatewayClient } from './gateway-client';
import { GatewaySession } from './gateway-session';
import {
logger,
printConnected,
printDisconnected,
printListening,
printModuleStatus,
printShuttingDown,
printToolList,
printWaiting,
@ -18,8 +19,8 @@ import type { ConfirmResourceAccess } from './tools/types';
export type { ConfirmResourceAccess, ResourceDecision } from './tools/types';
export interface DaemonOptions {
/** Called before a new connection. Return false to reject with HTTP 403. */
confirmConnect: (url: string) => Promise<boolean> | boolean;
/** Called before a new connection. Receives a pre-seeded session; may mutate it. Return false to reject with HTTP 403. */
confirmConnect: (url: string, session: GatewaySession) => Promise<boolean> | boolean;
/** Called when a tool is about to access a resource that requires confirmation. */
confirmResourceAccess: ConfirmResourceAccess;
/** Called after connect/disconnect for status propagation (e.g. Electron tray). */
@ -39,6 +40,7 @@ let settingsStorePromise: Promise<SettingsStore>;
interface DaemonState {
config: GatewayConfig;
client: GatewayClient | null;
session: GatewaySession | null;
connectedAt: string | null;
connectedUrl: string | null;
}
@ -46,6 +48,7 @@ interface DaemonState {
const state: DaemonState = {
config: undefined as unknown as GatewayConfig,
client: null,
session: null,
connectedAt: null,
connectedUrl: null,
};
@ -71,7 +74,7 @@ function jsonResponse(
}
function getDir(): string {
return state.config.filesystem.dir;
return state.session?.dir ?? state.config.filesystem.dir;
}
async function readBody(req: http.IncomingMessage): Promise<string> {
@ -136,26 +139,44 @@ async function handleConnect(req: http.IncomingMessage, res: http.ServerResponse
}
});
if (!isAllowed) {
const approved = await daemonOptions.confirmConnect(url);
if (!approved) {
jsonResponse(res, 403, { error: 'Connection rejected by user.' });
return;
}
}
try {
const store = settingsStore ?? (await settingsStorePromise);
settingsStore ??= store;
const defaults = store.getDefaults(state.config);
const session = new GatewaySession(defaults, store);
if (!isAllowed) {
const approved = await daemonOptions.confirmConnect(url, session);
if (!approved) {
jsonResponse(res, 403, { error: 'Connection rejected by user.' });
return;
}
}
// Validate the directory the session resolved to
try {
const stat = await fs.stat(session.dir);
if (!stat.isDirectory()) {
jsonResponse(res, 400, { error: `Invalid directory: ${session.dir}` });
return;
}
} catch {
jsonResponse(res, 400, { error: `Invalid directory: ${session.dir}` });
return;
}
state.session = session;
const client = new GatewayClient({
url: url.replace(/\/$/, ''),
apiKey: token,
config: state.config,
settingsStore: store,
session,
confirmResourceAccess: daemonOptions.confirmResourceAccess,
onPersistentFailure: () => {
state.client = null;
state.session = null;
state.connectedAt = null;
state.connectedUrl = null;
printDisconnected();
@ -186,6 +207,7 @@ async function handleDisconnect(res: http.ServerResponse): Promise<void> {
if (state.client) {
await state.client.disconnect();
state.client = null;
state.session = null;
state.connectedAt = null;
state.connectedUrl = null;
logger.debug('Disconnected');
@ -230,7 +252,7 @@ export function startDaemon(config: GatewayConfig, options: DaemonOptions): http
// SettingsStore is initialized asynchronously; the server starts immediately.
// handleConnect awaits this promise before proceeding, eliminating the race condition.
settingsStorePromise = SettingsStore.create(config);
settingsStorePromise = SettingsStore.create();
void settingsStorePromise
.then((store) => {
settingsStore = store;
@ -275,7 +297,6 @@ export function startDaemon(config: GatewayConfig, options: DaemonOptions): http
});
server.listen(port, '127.0.0.1', () => {
printModuleStatus(config);
printListening(port);
printWaiting();
});

View file

@ -3,6 +3,7 @@ import * as os from 'node:os';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { GatewayConfig } from './config';
import type { GatewaySession } from './gateway-session';
import {
logger,
printAuthFailure,
@ -13,7 +14,6 @@ import {
printToolCall,
printToolResult,
} from './logger';
import type { SettingsStore } from './settings-store';
import type { BrowserModule } from './tools/browser';
import { filesystemReadTools, filesystemWriteTools } from './tools/filesystem';
import { ShellModule } from './tools/shell';
@ -43,8 +43,10 @@ function tagCategory(defs: ToolDefinition[], category: string): ToolDefinition[]
export interface GatewayClientOptions {
url: string;
apiKey: string;
/** Non-permission config (browser, shell, logLevel, etc.) */
config: GatewayConfig;
settingsStore: SettingsStore;
/** Permissions + dir + session rules for this connection. */
session: GatewaySession;
confirmResourceAccess: ConfirmResourceAccess;
/** Called when the client gives up reconnecting after persistent auth failures. */
onPersistentFailure?: () => void;
@ -97,7 +99,7 @@ export class GatewayClient {
}
private get dir(): string {
return this.options.config.filesystem.dir;
return this.options.session.dir;
}
/** Start the client: upload capabilities, connect SSE, handle requests. */
@ -119,7 +121,7 @@ export class GatewayClient {
/** Notify the server we're disconnecting, then close the SSE connection. */
async disconnect(): Promise<void> {
this.shouldReconnect = false;
this.options.settingsStore.clearSessionRules();
this.options.session.clearSessionRules();
// POST the disconnect notification BEFORE closing EventSource.
// The EventSource keeps the Node.js event loop alive — if we close it
@ -155,13 +157,13 @@ export class GatewayClient {
private async getAllDefinitions(): Promise<ToolDefinition[]> {
if (this.allDefinitions) return this.allDefinitions;
const { config, settingsStore } = this.options;
const { config, session } = this.options;
const defs: ToolDefinition[] = [];
const categories: Array<{ name: string; enabled: boolean; writeAccess?: boolean }> = [];
// Filesystem
const fsReadEnabled = settingsStore.getGroupMode('filesystemRead') !== 'deny';
const fsWriteEnabled = settingsStore.getGroupMode('filesystemWrite') !== 'deny';
const fsReadEnabled = session.getGroupMode('filesystemRead') !== 'deny';
const fsWriteEnabled = session.getGroupMode('filesystemWrite') !== 'deny';
if (fsReadEnabled) {
defs.push(...tagCategory(filesystemReadTools, 'filesystem'));
}
@ -188,19 +190,19 @@ export class GatewayClient {
{
name: 'Shell',
category: 'shell',
enabled: settingsStore.getGroupMode('shell') !== 'deny',
enabled: session.getGroupMode('shell') !== 'deny',
module: ShellModule,
},
{
name: 'Screenshot',
category: 'screenshot',
enabled: settingsStore.getGroupMode('computer') !== 'deny',
enabled: session.getGroupMode('computer') !== 'deny',
module: ScreenshotModule,
},
{
name: 'MouseKeyboard',
category: 'mouse-keyboard',
enabled: settingsStore.getGroupMode('computer') !== 'deny',
enabled: session.getGroupMode('computer') !== 'deny',
module: MouseKeyboardModule,
},
];
@ -221,7 +223,7 @@ export class GatewayClient {
}
// Browser
if (settingsStore.getGroupMode('browser') !== 'deny') {
if (session.getGroupMode('browser') !== 'deny') {
const { BrowserModule: BrowserModuleClass } = await import('./tools/browser');
this.browserModule = await BrowserModuleClass.create({
...config.browser,
@ -418,10 +420,10 @@ export class GatewayClient {
resources: AffectedResource[],
decision?: ResourceDecision,
): Promise<void> {
const { settingsStore, confirmResourceAccess, config } = this.options;
const { session, confirmResourceAccess, config } = this.options;
for (const resource of resources) {
const rule = settingsStore.check(resource.toolGroup, resource.resource);
const rule = session.check(resource.toolGroup, resource.resource);
if (rule === 'deny') {
throw new Error(
@ -452,13 +454,13 @@ export class GatewayClient {
case 'allowOnce':
break;
case 'allowForSession':
settingsStore.allowForSession(resource.toolGroup, resource.resource);
session.allowForSession(resource.toolGroup, resource.resource);
break;
case 'alwaysAllow':
settingsStore.alwaysAllow(resource.toolGroup, resource.resource);
session.alwaysAllow(resource.toolGroup, resource.resource);
break;
case 'alwaysDeny':
settingsStore.alwaysDeny(resource.toolGroup, resource.resource);
session.alwaysDeny(resource.toolGroup, resource.resource);
throw new Error(
`User permanently denied access to ${resource.toolGroup}: ${resource.resource}`,
);

View file

@ -0,0 +1,283 @@
import type { ToolGroup } from './config';
import { GatewaySession, buildDefaultPermissions } from './gateway-session';
import type { SettingsStore } from './settings-store';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeStore(
overrides: Partial<{
allow: Record<string, string[]>;
deny: Record<string, string[]>;
}> = {},
): jest.Mocked<
Pick<SettingsStore, 'getResourcePermissions' | 'alwaysAllow' | 'alwaysDeny' | 'flush'>
> {
return {
getResourcePermissions: jest.fn((toolGroup: ToolGroup) => ({
allow: overrides.allow?.[toolGroup] ?? [],
deny: overrides.deny?.[toolGroup] ?? [],
})),
alwaysAllow: jest.fn(),
alwaysDeny: jest.fn(),
flush: jest.fn().mockResolvedValue(undefined),
};
}
const FULL_ALLOW_PERMISSIONS = buildDefaultPermissions({
filesystemRead: 'allow',
filesystemWrite: 'allow',
shell: 'allow',
computer: 'allow',
browser: 'allow',
});
const FULL_ASK_PERMISSIONS = buildDefaultPermissions({
filesystemRead: 'ask',
filesystemWrite: 'ask',
shell: 'ask',
computer: 'ask',
browser: 'ask',
});
// ---------------------------------------------------------------------------
// buildDefaultPermissions
// ---------------------------------------------------------------------------
describe('buildDefaultPermissions', () => {
it('fills missing groups with TOOL_GROUP_DEFINITIONS defaults', () => {
const result = buildDefaultPermissions({});
expect(result).toEqual({
filesystemRead: 'allow',
filesystemWrite: 'ask',
shell: 'deny',
computer: 'deny',
browser: 'ask',
});
});
it('applies provided overrides', () => {
const result = buildDefaultPermissions({ shell: 'allow', computer: 'allow' });
expect(result.shell).toBe('allow');
expect(result.computer).toBe('allow');
// others still default
expect(result.filesystemRead).toBe('allow');
});
});
// ---------------------------------------------------------------------------
// GatewaySession — construction and setters
// ---------------------------------------------------------------------------
describe('GatewaySession', () => {
describe('construction', () => {
it('copies permissions and dir from defaults', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/home/user' },
store as unknown as SettingsStore,
);
expect(session.dir).toBe('/home/user');
expect(session.getAllPermissions()).toEqual(FULL_ALLOW_PERMISSIONS);
});
it('does not share the permissions object with the caller', () => {
const store = makeStore();
const defaults = { ...FULL_ALLOW_PERMISSIONS };
const session = new GatewaySession(
{ permissions: defaults, dir: '/' },
store as unknown as SettingsStore,
);
defaults.shell = 'deny';
expect(session.getAllPermissions().shell).toBe('allow');
});
});
describe('setPermissions / setDir', () => {
it('updates permissions for the session', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
);
session.setPermissions({ ...FULL_ALLOW_PERMISSIONS, shell: 'deny' });
expect(session.getAllPermissions().shell).toBe('deny');
});
it('updates dir for the session', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/old' },
store as unknown as SettingsStore,
);
session.setDir('/new');
expect(session.dir).toBe('/new');
});
});
// ---------------------------------------------------------------------------
// getGroupMode
// ---------------------------------------------------------------------------
describe('getGroupMode', () => {
it('returns the configured mode for each group', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: FULL_ASK_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
);
expect(session.getGroupMode('filesystemRead')).toBe('ask');
expect(session.getGroupMode('shell')).toBe('ask');
});
it('forces filesystemWrite to deny when filesystemRead is deny', () => {
const store = makeStore();
const session = new GatewaySession(
{
permissions: buildDefaultPermissions({
filesystemRead: 'deny',
filesystemWrite: 'allow',
}),
dir: '/',
},
store as unknown as SettingsStore,
);
expect(session.getGroupMode('filesystemWrite')).toBe('deny');
});
it('does not force filesystemWrite when filesystemRead is ask or allow', () => {
const store = makeStore();
const session = new GatewaySession(
{
permissions: buildDefaultPermissions({ filesystemRead: 'ask', filesystemWrite: 'allow' }),
dir: '/',
},
store as unknown as SettingsStore,
);
expect(session.getGroupMode('filesystemWrite')).toBe('allow');
});
});
// ---------------------------------------------------------------------------
// check — permission evaluation order
// ---------------------------------------------------------------------------
describe('check', () => {
it('returns deny when resource is in the persistent deny list (overrides group mode)', () => {
const store = makeStore({ deny: { shell: ['rm -rf /'] } });
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
);
expect(session.check('shell', 'rm -rf /')).toBe('deny');
});
it('returns allow when resource is in the persistent allow list', () => {
const store = makeStore({ allow: { browser: ['example.com'] } });
const session = new GatewaySession(
{
permissions: buildDefaultPermissions({ browser: 'ask' }),
dir: '/',
},
store as unknown as SettingsStore,
);
expect(session.check('browser', 'example.com')).toBe('allow');
});
it('persistent deny takes priority over persistent allow', () => {
const store = makeStore({
allow: { shell: ['npm'] },
deny: { shell: ['npm'] },
});
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
);
expect(session.check('shell', 'npm')).toBe('deny');
});
it('returns allow for a session-allowed resource', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
store as unknown as SettingsStore,
);
session.allowForSession('shell', 'npm');
expect(session.check('shell', 'npm')).toBe('allow');
});
it('falls through to group mode when no rules match', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
store as unknown as SettingsStore,
);
expect(session.check('shell', 'npm')).toBe('ask');
});
});
// ---------------------------------------------------------------------------
// Session-level allow rules
// ---------------------------------------------------------------------------
describe('allowForSession / clearSessionRules', () => {
it('allow for session is cleared after clearSessionRules', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
store as unknown as SettingsStore,
);
session.allowForSession('shell', 'npm');
expect(session.check('shell', 'npm')).toBe('allow');
session.clearSessionRules();
expect(session.check('shell', 'npm')).toBe('ask');
});
it('session allow for one group does not affect another', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask', browser: 'ask' }), dir: '/' },
store as unknown as SettingsStore,
);
session.allowForSession('shell', 'npm');
expect(session.check('browser', 'npm')).toBe('ask');
});
});
// ---------------------------------------------------------------------------
// Persistent rule delegation
// ---------------------------------------------------------------------------
describe('alwaysAllow / alwaysDeny delegation', () => {
it('delegates alwaysAllow to the settings store', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
);
session.alwaysAllow('shell', 'npm');
expect(store.alwaysAllow).toHaveBeenCalledWith('shell', 'npm');
});
it('delegates alwaysDeny to the settings store', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
);
session.alwaysDeny('shell', 'rm -rf /');
expect(store.alwaysDeny).toHaveBeenCalledWith('shell', 'rm -rf /');
});
it('delegates flush to the settings store', async () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
store as unknown as SettingsStore,
);
await session.flush();
expect(store.flush).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,142 @@
import type { PermissionMode, ToolGroup } from './config';
import { TOOL_GROUP_DEFINITIONS } from './config';
import type { SettingsStore } from './settings-store';
/**
* Holds all session-scoped state for a single gateway connection.
*
* Tool group permission modes and the working directory are mutable at connect
* time (via the confirmConnect prompt) but are never written back to the
* settings file. Only alwaysAllow / alwaysDeny resource rules persist across
* sessions, via delegation to SettingsStore.
*/
export class GatewaySession {
private _dir: string;
private _permissions: Record<ToolGroup, PermissionMode>;
private readonly sessionAllows: Map<ToolGroup, Set<string>> = new Map();
constructor(
defaults: { permissions: Record<ToolGroup, PermissionMode>; dir: string },
private readonly settingsStore: SettingsStore,
) {
this._permissions = { ...defaults.permissions };
this._dir = defaults.dir;
}
// ---------------------------------------------------------------------------
// Mutable session settings
// ---------------------------------------------------------------------------
setPermissions(p: Record<ToolGroup, PermissionMode>): void {
this._permissions = { ...p };
}
setDir(dir: string): void {
this._dir = dir;
}
// ---------------------------------------------------------------------------
// Read session settings
// ---------------------------------------------------------------------------
get dir(): string {
return this._dir;
}
/** Return a snapshot of the current session permission modes. */
getAllPermissions(): Record<ToolGroup, PermissionMode> {
return { ...this._permissions };
}
/**
* Return the effective permission mode for a tool group.
* Enforces the spec constraint: filesystemRead=deny forces filesystemWrite=deny.
*/
getGroupMode(toolGroup: ToolGroup): PermissionMode {
if (toolGroup === 'filesystemWrite' && this._permissions['filesystemRead'] === 'deny') {
return 'deny';
}
return this._permissions[toolGroup] ?? 'ask';
}
// ---------------------------------------------------------------------------
// Permission check
// ---------------------------------------------------------------------------
/**
* Check the effective permission for a resource.
* Evaluation order:
* 1. Persistent deny list 'deny' (takes absolute priority even in Allow mode)
* 2. Persistent allow list 'allow'
* 3. Session allow set 'allow'
* 4. Group mode via getGroupMode() (includes cross-group constraints)
*/
check(toolGroup: ToolGroup, resource: string): PermissionMode {
const rp = this.settingsStore.getResourcePermissions(toolGroup);
if (rp.deny.includes(resource)) return 'deny';
if (rp.allow.includes(resource)) return 'allow';
if (this.hasSessionAllow(toolGroup, resource)) return 'allow';
return this.getGroupMode(toolGroup);
}
// ---------------------------------------------------------------------------
// Session-scoped allow rules
// ---------------------------------------------------------------------------
allowForSession(toolGroup: ToolGroup, resource: string): void {
let set = this.sessionAllows.get(toolGroup);
if (!set) {
set = new Set();
this.sessionAllows.set(toolGroup, set);
}
set.add(resource);
}
clearSessionRules(): void {
this.sessionAllows.clear();
}
// ---------------------------------------------------------------------------
// Persistent rules — delegate to SettingsStore
// ---------------------------------------------------------------------------
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
this.settingsStore.alwaysAllow(toolGroup, resource);
}
alwaysDeny(toolGroup: ToolGroup, resource: string): void {
this.settingsStore.alwaysDeny(toolGroup, resource);
}
// ---------------------------------------------------------------------------
// Shutdown
// ---------------------------------------------------------------------------
/** Flush pending persistent writes — must be called on shutdown. */
async flush(): Promise<void> {
return await this.settingsStore.flush();
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private hasSessionAllow(toolGroup: ToolGroup, resource: string): boolean {
return this.sessionAllows.get(toolGroup)?.has(resource) ?? false;
}
}
/**
* Build a full permissions record from TOOL_GROUP_DEFINITIONS defaults,
* merged with the provided partial overrides.
*/
export function buildDefaultPermissions(
overrides: Partial<Record<ToolGroup, PermissionMode>>,
): Record<ToolGroup, PermissionMode> {
return Object.fromEntries(
(Object.keys(TOOL_GROUP_DEFINITIONS) as ToolGroup[]).map((g) => [
g,
overrides[g] ?? TOOL_GROUP_DEFINITIONS[g].default,
]),
) as Record<ToolGroup, PermissionMode>;
}

View file

@ -0,0 +1,211 @@
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
jest.mock('node:os', () => {
const actual = jest.requireActual<typeof os>('node:os');
return { ...actual, homedir: jest.fn(() => actual.homedir()) };
});
import type { GatewayConfig } from './config';
import { SettingsStore } from './settings-store';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseJson<T>(raw: string): T {
try {
return JSON.parse(raw) as T;
} catch {
throw new Error(`Failed to parse JSON: ${raw}`);
}
}
const BASE_CONFIG: GatewayConfig = {
logLevel: 'info',
port: 7655,
allowedOrigins: [],
filesystem: { dir: process.cwd() },
computer: { shell: { timeout: 30_000 } },
browser: { defaultBrowser: 'chrome' },
permissions: {},
permissionConfirmation: 'instance',
};
async function createStore(
tmpDir: string,
initial?: Record<string, unknown>,
): Promise<SettingsStore> {
(os.homedir as jest.Mock).mockReturnValue(tmpDir);
if (initial !== undefined) {
const dir = path.join(tmpDir, '.n8n-gateway');
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'settings.json'), JSON.stringify(initial), 'utf-8');
}
return await SettingsStore.create();
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'settings-store-test-'));
});
afterEach(async () => {
jest.restoreAllMocks();
await fs.rm(tmpDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// SettingsStore.create
// ---------------------------------------------------------------------------
describe('SettingsStore.create', () => {
it('creates a store when no file exists', async () => {
const store = await createStore(tmpDir);
// Should not throw; resource permissions are empty
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
});
it('loads permissions and resource rules from file', async () => {
const store = await createStore(tmpDir, {
permissions: { shell: 'allow' },
resourcePermissions: { shell: { allow: ['npm'], deny: [] } },
});
expect(store.getResourcePermissions('shell')).toEqual({ allow: ['npm'], deny: [] });
});
it('tolerates a malformed file and starts with empty state', async () => {
(os.homedir as jest.Mock).mockReturnValue(tmpDir);
const filePath = path.join(tmpDir, '.n8n-gateway', 'settings.json');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, 'not-json', 'utf-8');
const store = await SettingsStore.create();
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
});
});
// ---------------------------------------------------------------------------
// getDefaults
// ---------------------------------------------------------------------------
describe('getDefaults', () => {
it('returns TOOL_GROUP_DEFINITIONS defaults when no file and no overrides', async () => {
const store = await createStore(tmpDir);
const { permissions } = store.getDefaults(BASE_CONFIG);
expect(permissions).toEqual({
filesystemRead: 'allow',
filesystemWrite: 'ask',
shell: 'deny',
computer: 'deny',
browser: 'ask',
});
});
it('CLI/ENV overrides take priority over file permissions', async () => {
const store = await createStore(tmpDir, {
permissions: { shell: 'ask' },
});
const config = { ...BASE_CONFIG, permissions: { shell: 'allow' as const } };
const { permissions } = store.getDefaults(config);
expect(permissions.shell).toBe('allow');
});
it('file permissions fill in groups not overridden by CLI/ENV', async () => {
const store = await createStore(tmpDir, {
permissions: { browser: 'deny' },
resourcePermissions: {},
});
const { permissions } = store.getDefaults(BASE_CONFIG);
expect(permissions.browser).toBe('deny');
// others fall back to TOOL_GROUP_DEFINITIONS defaults
expect(permissions.filesystemRead).toBe('allow');
});
it('uses an explicit --filesystem-dir CLI value over the stored dir', async () => {
const store = await createStore(tmpDir, { filesystemDir: '/stored' });
const config = { ...BASE_CONFIG, filesystem: { dir: '/explicit' } };
const { dir } = store.getDefaults(config);
expect(dir).toBe('/explicit');
});
it('falls back to stored filesystemDir when config dir equals cwd', async () => {
const store = await createStore(tmpDir, {
permissions: {},
resourcePermissions: {},
filesystemDir: '/stored',
});
const { dir } = store.getDefaults(BASE_CONFIG); // BASE_CONFIG.filesystem.dir = process.cwd()
expect(dir).toBe('/stored');
});
it('falls back to process.cwd() when neither config dir nor stored dir is set', async () => {
const store = await createStore(tmpDir, { filesystemDir: '' });
const { dir } = store.getDefaults(BASE_CONFIG);
expect(dir).toBe(process.cwd());
});
});
// ---------------------------------------------------------------------------
// getResourcePermissions
// ---------------------------------------------------------------------------
describe('getResourcePermissions', () => {
it('returns empty lists for unknown groups', async () => {
const store = await createStore(tmpDir);
expect(store.getResourcePermissions('shell')).toEqual({ allow: [], deny: [] });
});
it('reflects alwaysAllow additions', async () => {
const store = await createStore(tmpDir);
store.alwaysAllow('shell', 'npm');
expect(store.getResourcePermissions('shell').allow).toContain('npm');
});
it('reflects alwaysDeny additions', async () => {
const store = await createStore(tmpDir);
store.alwaysDeny('shell', 'rm -rf /');
expect(store.getResourcePermissions('shell').deny).toContain('rm -rf /');
});
});
// ---------------------------------------------------------------------------
// alwaysAllow / alwaysDeny deduplication
// ---------------------------------------------------------------------------
describe('alwaysAllow / alwaysDeny deduplication', () => {
it('does not add a duplicate allow entry', async () => {
const store = await createStore(tmpDir);
store.alwaysAllow('shell', 'npm');
store.alwaysAllow('shell', 'npm');
expect(store.getResourcePermissions('shell').allow.filter((r) => r === 'npm')).toHaveLength(1);
});
it('does not add a duplicate deny entry', async () => {
const store = await createStore(tmpDir);
store.alwaysDeny('shell', 'rm');
store.alwaysDeny('shell', 'rm');
expect(store.getResourcePermissions('shell').deny.filter((r) => r === 'rm')).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// flush — writes pending changes immediately
// ---------------------------------------------------------------------------
describe('flush', () => {
it('writes alwaysAllow changes to disk', async () => {
const store = await createStore(tmpDir);
store.alwaysAllow('shell', 'npm');
await store.flush();
const raw = await fs.readFile(path.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<{ resourcePermissions: { shell: { allow: string[] } } }>(raw);
expect(parsed.resourcePermissions.shell.allow).toContain('npm');
});
});

View file

@ -69,12 +69,6 @@ function emptySettings(): PersistentSettings {
// ---------------------------------------------------------------------------
export class SettingsStore {
/** Permissions merged from persistent settings + startup overrides — single source of truth. */
private effectivePermissions: Partial<Record<ToolGroup, PermissionMode>>;
/** Session-level allow rules: cleared on disconnect. */
private sessionAllows: Map<ToolGroup, Set<string>> = new Map();
// Write queue state
private writeTimer: ReturnType<typeof setTimeout> | null = null;
private inFlightPromise: Promise<void> | null = null;
@ -83,72 +77,67 @@ export class SettingsStore {
private constructor(
private persistent: PersistentSettings,
startupOverrides: Partial<Record<ToolGroup, PermissionMode>>,
private readonly filePath: string,
) {
// Merge once at init — startup overrides shadow persistent permissions.
this.effectivePermissions = { ...persistent.permissions, ...startupOverrides };
}
) {}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
static async create(config: GatewayConfig): Promise<SettingsStore> {
static async create(): Promise<SettingsStore> {
const filePath = getSettingsFilePath();
const persistent = await loadFromFile(filePath);
const store = new SettingsStore(persistent, config.permissions, filePath);
store.validateHasActiveGroup();
return store;
return new SettingsStore(persistent, filePath);
}
// ---------------------------------------------------------------------------
// Permission check
// Session defaults
// ---------------------------------------------------------------------------
/**
* Return the effective permission mode for a tool group.
* Enforces the spec constraint: filesystemRead=deny forces filesystemWrite=deny.
* Merge file permissions with CLI/ENV overrides to produce the defaults
* for a new GatewaySession.
*
* Priority: CLI/ENV overrides > persistent file permissions > TOOL_GROUP_DEFINITIONS defaults
* Dir: config.filesystem.dir (if explicitly set) > persistent filesystemDir > process.cwd()
*/
getGroupMode(toolGroup: ToolGroup): PermissionMode {
if (
toolGroup === 'filesystemWrite' &&
(this.effectivePermissions['filesystemRead'] ?? 'ask') === 'deny'
) {
return 'deny';
}
return this.effectivePermissions[toolGroup] ?? 'ask';
getDefaults(config: GatewayConfig): {
permissions: Record<ToolGroup, PermissionMode>;
dir: string;
} {
const permissions = Object.fromEntries(
(Object.keys(TOOL_GROUP_DEFINITIONS) as ToolGroup[]).map((g) => [
g,
config.permissions[g] ??
this.persistent.permissions[g] ??
TOOL_GROUP_DEFINITIONS[g].default,
]),
) as Record<ToolGroup, PermissionMode>;
const configDirIsDefault = config.filesystem.dir === process.cwd();
const storedDir = this.persistent.filesystemDir;
const dir =
(!configDirIsDefault ? config.filesystem.dir : null) ??
(storedDir !== '' ? storedDir : null) ??
process.cwd();
return { permissions, dir };
}
/**
* Check the effective permission for a resource.
* Evaluation order:
* 1. Persistent deny list 'deny' (takes absolute priority even in Allow mode)
* 2. Persistent allow list 'allow'
* 3. Session allow set 'allow'
* 4. Effective group mode via getGroupMode() (includes cross-group constraints)
*/
check(toolGroup: ToolGroup, resource: string): PermissionMode {
// ---------------------------------------------------------------------------
// Resource permissions
// ---------------------------------------------------------------------------
/** Read persistent resource rules for a tool group — used by GatewaySession.check(). */
getResourcePermissions(toolGroup: ToolGroup): { allow: string[]; deny: string[] } {
const rp = this.persistent.resourcePermissions[toolGroup];
if (rp?.deny.includes(resource)) return 'deny';
if (rp?.allow.includes(resource)) return 'allow';
if (this.hasSessionAllow(toolGroup, resource)) return 'allow';
return this.getGroupMode(toolGroup);
return { allow: rp?.allow ?? [], deny: rp?.deny ?? [] };
}
// ---------------------------------------------------------------------------
// Mutation methods
// ---------------------------------------------------------------------------
allowForSession(toolGroup: ToolGroup, resource: string): void {
let set = this.sessionAllows.get(toolGroup);
if (!set) {
set = new Set();
this.sessionAllows.set(toolGroup, set);
}
set.add(resource);
}
alwaysAllow(toolGroup: ToolGroup, resource: string): void {
const rp = this.getOrInitResourcePermissions(toolGroup);
if (!rp.allow.includes(resource)) {
@ -165,11 +154,7 @@ export class SettingsStore {
}
}
clearSessionRules(): void {
this.sessionAllows.clear();
}
/** Force immediate write — must be called on daemon shutdown. */
/** Force immediate write — must be called on shutdown. */
async flush(): Promise<void> {
this.cancelDebounce();
if (this.inFlightPromise) await this.inFlightPromise;
@ -180,29 +165,17 @@ export class SettingsStore {
// Private helpers
// ---------------------------------------------------------------------------
/** Throws if every tool group is set to Deny — at least one must be Ask or Allow to start. */
private validateHasActiveGroup(): void {
const allDeny = (Object.keys(TOOL_GROUP_DEFINITIONS) as ToolGroup[]).every(
(g) => this.getGroupMode(g) === 'deny',
);
if (allDeny) {
throw new Error(
'All tool groups are set to Deny — at least one must be Ask or Allow to start the gateway',
);
}
}
private hasSessionAllow(toolGroup: ToolGroup, resource: string): boolean {
return this.sessionAllows.get(toolGroup)?.has(resource) ?? false;
}
private getOrInitResourcePermissions(toolGroup: ToolGroup): ResourcePermissions {
let rp = this.persistent.resourcePermissions[toolGroup];
if (!rp) {
rp = { allow: [], deny: [] };
this.persistent.resourcePermissions[toolGroup] = rp;
const existing = this.persistent.resourcePermissions[toolGroup];
if (existing) {
// Normalise: zod schema marks allow/deny as optional; ensure they exist.
existing.allow ??= [];
existing.deny ??= [];
return existing as ResourcePermissions;
}
return rp;
const fresh: ResourcePermissions = { allow: [], deny: [] };
this.persistent.resourcePermissions[toolGroup] = fresh;
return fresh;
}
private scheduleWrite(): void {
@ -259,7 +232,7 @@ export class SettingsStore {
}
}
private async persist(): Promise<void> {
async persist(): Promise<void> {
const dir = path.dirname(this.filePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
await fs.writeFile(this.filePath, JSON.stringify(this.persistent, null, 2), {

View file

@ -1,5 +1,26 @@
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as nodePath from 'node:path';
jest.mock('node:os', () => {
const actual = jest.requireActual<typeof os>('node:os');
return { ...actual, homedir: jest.fn(() => actual.homedir()) };
});
import type { GatewayConfig } from './config';
import { applyTemplate, resolveTemplateName } from './startup-config-cli';
import { ensureSettingsFile, isAllDeny } from './startup-config-cli';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseJson<T>(raw: string): T {
try {
return JSON.parse(raw) as T;
} catch {
throw new Error(`Failed to parse JSON: ${raw}`);
}
}
const BASE_CONFIG: GatewayConfig = {
logLevel: 'info',
@ -14,54 +35,119 @@ const BASE_CONFIG: GatewayConfig = {
permissionConfirmation: 'instance',
};
describe('resolveTemplateName', () => {
it('returns recommended for undefined', () => {
expect(resolveTemplateName(undefined)).toBe('default');
// ---------------------------------------------------------------------------
// isAllDeny
// ---------------------------------------------------------------------------
describe('isAllDeny', () => {
it('returns true when all groups are deny', () => {
expect(
isAllDeny({
filesystemRead: 'deny',
filesystemWrite: 'deny',
shell: 'deny',
computer: 'deny',
browser: 'deny',
}),
).toBe(true);
});
it('returns recommended for unknown value', () => {
expect(resolveTemplateName('bogus')).toBe('default');
it('returns false when at least one group is ask', () => {
expect(
isAllDeny({
filesystemRead: 'ask',
filesystemWrite: 'deny',
shell: 'deny',
computer: 'deny',
browser: 'deny',
}),
).toBe(false);
});
it.each(['default', 'yolo', 'custom'] as const)('returns %s for valid name', (name) => {
expect(resolveTemplateName(name)).toBe(name);
it('returns false when at least one group is allow', () => {
expect(
isAllDeny({
filesystemRead: 'allow',
filesystemWrite: 'deny',
shell: 'deny',
computer: 'deny',
browser: 'deny',
}),
).toBe(false);
});
});
describe('applyTemplate', () => {
it('applies recommended template permissions', () => {
const result = applyTemplate(BASE_CONFIG, 'default');
expect(result.permissions).toMatchObject({
// ---------------------------------------------------------------------------
// ensureSettingsFile
// ---------------------------------------------------------------------------
describe('ensureSettingsFile', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'gateway-test-'));
// Point getSettingsFilePath() at our temp location by overriding homedir
(os.homedir as jest.Mock).mockReturnValue(tmpDir);
});
afterEach(async () => {
jest.restoreAllMocks();
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('creates the settings file with recommended defaults when absent', async () => {
await ensureSettingsFile(BASE_CONFIG);
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<Record<string, unknown>>(raw);
expect(parsed.permissions).toMatchObject({
filesystemRead: 'allow',
filesystemWrite: 'ask',
shell: 'deny',
computer: 'deny',
browser: 'ask',
});
expect(parsed.filesystemDir).toBe('');
expect(parsed.resourcePermissions).toEqual({});
});
it('applies yolo template permissions', () => {
const result = applyTemplate(BASE_CONFIG, 'yolo');
for (const mode of Object.values(result.permissions)) {
expect(mode).toBe('allow');
}
});
it('CLI/ENV overrides in config.permissions win over template', () => {
it('merges CLI/ENV permission overrides on top of the template', async () => {
const config: GatewayConfig = {
...BASE_CONFIG,
permissions: { shell: 'allow' }, // explicit CLI override
permissions: { shell: 'allow' },
};
const result = applyTemplate(config, 'default');
// recommended says shell: deny, but CLI override says allow
expect(result.permissions.shell).toBe('allow');
// Other fields come from template
expect(result.permissions.filesystemRead).toBe('allow');
await ensureSettingsFile(config);
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<{ permissions: Record<string, string> }>(raw);
// CLI override wins
expect(parsed.permissions.shell).toBe('allow');
// Template defaults for the rest
expect(parsed.permissions.filesystemRead).toBe('allow');
});
it('does not mutate the input config', () => {
const config: GatewayConfig = { ...BASE_CONFIG, permissions: {} };
applyTemplate(config, 'yolo');
expect(config.permissions).toEqual({});
it('does not overwrite an existing settings file', async () => {
const dir = nodePath.join(tmpDir, '.n8n-gateway');
const file = nodePath.join(dir, 'settings.json');
await fs.mkdir(dir, { recursive: true });
const existing = JSON.stringify({ permissions: { shell: 'allow' }, filesystemDir: '/custom' });
await fs.writeFile(file, existing, 'utf-8');
await ensureSettingsFile(BASE_CONFIG);
const raw = await fs.readFile(file, 'utf-8');
expect(raw).toBe(existing);
});
it('is safe to call multiple times — only creates once', async () => {
await ensureSettingsFile(BASE_CONFIG);
await ensureSettingsFile(BASE_CONFIG);
// Second call must not throw and must not alter the file
const raw = await fs.readFile(nodePath.join(tmpDir, '.n8n-gateway', 'settings.json'), 'utf-8');
const parsed = parseJson<Record<string, unknown>>(raw);
expect(parsed.filesystemDir).toBe('');
});
});

View file

@ -1,17 +1,16 @@
import { select, confirm, input } from '@inquirer/prompts';
import { select, input } from '@inquirer/prompts';
import * as fs from 'node:fs/promises';
import * as nodePath from 'node:path';
import type { GatewayConfig, PermissionMode, ToolGroup } from './config';
import { PERMISSION_MODES, getSettingsFilePath, TOOL_GROUP_DEFINITIONS } from './config';
import type { ConfigTemplate, TemplateName } from './config-templates';
import { CONFIG_TEMPLATES, getTemplate } from './config-templates';
import { getTemplate } from './config-templates';
// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------
const GROUP_LABELS: Record<ToolGroup, string> = {
export const GROUP_LABELS: Record<ToolGroup, string> = {
filesystemRead: 'Filesystem Read',
filesystemWrite: 'Filesystem Write',
shell: 'Shell Execution',
@ -19,7 +18,7 @@ const GROUP_LABELS: Record<ToolGroup, string> = {
browser: 'Browser Automation',
};
function printPermissionsTable(permissions: Record<ToolGroup, PermissionMode>): void {
export function printPermissionsTable(permissions: Record<ToolGroup, PermissionMode>): void {
console.log();
console.log(' Current permissions:');
for (const group of Object.keys(TOOL_GROUP_DEFINITIONS) as ToolGroup[]) {
@ -30,62 +29,11 @@ function printPermissionsTable(permissions: Record<ToolGroup, PermissionMode>):
console.log();
}
// ---------------------------------------------------------------------------
// Settings file I/O (minimal — only reads/writes permissions and filesystemDir)
// ---------------------------------------------------------------------------
async function loadPersistedPermissions(): Promise<Partial<
Record<ToolGroup, PermissionMode>
> | null> {
try {
const raw = await fs.readFile(getSettingsFilePath(), 'utf-8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const perms = parsed.permissions;
if (typeof perms !== 'object' || perms === null) return null;
if (Object.keys(perms).length === 0) return null;
return perms as Partial<Record<ToolGroup, PermissionMode>>;
} catch {
return null;
}
}
async function saveStartupConfig(
permissions: Record<ToolGroup, PermissionMode>,
filesystemDir: string,
): Promise<void> {
const filePath = getSettingsFilePath();
// Preserve existing resource-level rules while updating permissions + dir
let existing: Record<string, unknown> = { resourcePermissions: {} };
try {
const raw = await fs.readFile(filePath, 'utf-8');
existing = JSON.parse(raw) as Record<string, unknown>;
} catch {
// File absent or malformed — start fresh
}
await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify({ ...existing, permissions, filesystemDir }, null, 2),
'utf-8',
);
}
// ---------------------------------------------------------------------------
// Interactive prompts
// ---------------------------------------------------------------------------
async function selectTemplate(): Promise<ConfigTemplate> {
return await select({
message: 'No configuration found. Choose a starting template',
choices: CONFIG_TEMPLATES.map((template) => ({
name: template.label,
description: template.description,
value: template,
})),
});
}
async function editPermissions(
export async function editPermissions(
current: Record<ToolGroup, PermissionMode>,
): Promise<Record<ToolGroup, PermissionMode>> {
const result = { ...current };
@ -100,10 +48,11 @@ async function editPermissions(
return result;
}
async function promptFilesystemDir(currentDir: string): Promise<string> {
export async function promptFilesystemDir(currentDir: string): Promise<string> {
const defaultDir = currentDir || process.cwd();
const rawDir = await input({
message: 'Filesystem root directory',
default: currentDir,
message: 'AI working directory',
default: defaultDir,
validate: async (dir: string) => {
const resolved = nodePath.resolve(dir);
try {
@ -120,7 +69,7 @@ async function promptFilesystemDir(currentDir: string): Promise<string> {
return nodePath.resolve(rawDir);
}
function isAllDeny(permissions: Record<ToolGroup, PermissionMode>): boolean {
export function isAllDeny(permissions: Record<ToolGroup, PermissionMode>): boolean {
return (Object.keys(TOOL_GROUP_DEFINITIONS) as ToolGroup[]).every(
(g) => permissions[g] === 'deny',
);
@ -131,83 +80,30 @@ function isAllDeny(permissions: Record<ToolGroup, PermissionMode>): boolean {
// ---------------------------------------------------------------------------
/**
* Run the interactive startup configuration prompt.
* Returns an updated GatewayConfig with user-chosen permissions and filesystem dir.
* Persists the result to the settings file.
* Silently creates the settings file with the default (Recommended) template
* if it does not exist. Merges CLI/ENV overrides from config.permissions on top.
* filesystemDir is left empty. Does NOT prompt. Safe to call on every startup.
*/
export async function runStartupConfigCli(config: GatewayConfig): Promise<GatewayConfig> {
const existing = await loadPersistedPermissions();
let permissions: Record<ToolGroup, PermissionMode>;
export async function ensureSettingsFile(config: GatewayConfig): Promise<void> {
const filePath = getSettingsFilePath();
if (existing === null) {
// First run — show template selection
const tpl = await selectTemplate();
// Merge startup CLI/ENV overrides on top of template
permissions = { ...tpl.permissions, ...config.permissions } as Record<
ToolGroup,
PermissionMode
>;
// Custom template: go straight to per-group editing
if (tpl.name === 'custom') {
permissions = await editPermissions(permissions);
} else {
printPermissionsTable(permissions);
if (!(await confirm({ message: 'Confirm?', default: true }))) {
permissions = await editPermissions(permissions);
}
}
} else {
// Existing config — merge file permissions and startup CLI/ENV overrides
const merged = Object.fromEntries(
(Object.keys(TOOL_GROUP_DEFINITIONS) as ToolGroup[]).map((g) => [
g,
config.permissions[g] ?? existing[g] ?? TOOL_GROUP_DEFINITIONS[g].default,
]),
) as Record<ToolGroup, PermissionMode>;
printPermissionsTable(merged);
if (!(await confirm({ message: 'Confirm?', default: true }))) {
permissions = await editPermissions(merged);
} else {
permissions = merged;
}
// Only create if truly absent — never overwrite an existing file.
try {
await fs.access(filePath);
return; // File exists — nothing to do.
} catch {
// File does not exist — proceed to create.
}
// At least one group must be Ask or Allow (spec: gateway will not start otherwise)
while (isAllDeny(permissions)) {
console.log('\n At least one capability must be Ask or Allow. Please edit the permissions.\n');
permissions = await editPermissions(permissions);
}
const template = getTemplate('default');
const permissions = { ...template.permissions, ...config.permissions };
// Filesystem dir — required when any filesystem group is active
const filesystemActive =
permissions.filesystemRead !== 'deny' || permissions.filesystemWrite !== 'deny';
const filesystemDir = filesystemActive
? await promptFilesystemDir(config.filesystem.dir)
: config.filesystem.dir;
const content = JSON.stringify(
{ permissions, filesystemDir: '', resourcePermissions: {} },
null,
2,
);
await saveStartupConfig(permissions, filesystemDir);
return { ...config, permissions, filesystem: { ...config.filesystem, dir: filesystemDir } };
}
/**
* Return the template name for display purposes given a `--template` CLI flag value.
* Falls back to 'default' for unknown values.
*/
export function resolveTemplateName(raw: string | undefined): TemplateName {
if (raw === 'yolo' || raw === 'custom' || raw === 'default') return raw;
return 'default';
}
/**
* Apply a named template to a config, merging existing CLI/ENV overrides on top.
* Useful for non-interactive pre-seeding (e.g. `--template yolo` in tests or CI).
*/
export function applyTemplate(config: GatewayConfig, templateName: TemplateName): GatewayConfig {
const tpl = getTemplate(templateName);
return {
...config,
permissions: { ...tpl.permissions, ...config.permissions },
};
await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: 'utf-8', mode: 0o600 });
}

View file

@ -1,6 +1,7 @@
import type { GatewayConfig } from '@n8n/computer-use/config';
import type { DaemonOptions } from '@n8n/computer-use/daemon';
import { startDaemon } from '@n8n/computer-use/daemon';
import type { GatewaySession } from '@n8n/computer-use/gateway-session';
import { logger } from '@n8n/computer-use/logger';
import { EventEmitter } from 'node:events';
import type * as http from 'node:http';
@ -32,7 +33,10 @@ export class DaemonController extends EventEmitter<DaemonControllerEvents> {
return this._status !== 'stopped';
}
start(config: GatewayConfig, confirmConnect: (url: string) => boolean): void {
start(
config: GatewayConfig,
confirmConnect: (url: string, session: GatewaySession) => boolean,
): void {
if (this.server) {
logger.debug('Daemon start requested but already running — ignoring');
return;

View file

@ -1,3 +1,4 @@
import type { GatewaySession } from '@n8n/computer-use/gateway-session';
import { configure, logger } from '@n8n/computer-use/logger';
import { app, dialog } from 'electron';
import * as path from 'node:path';
@ -38,7 +39,7 @@ app
const preloadPath = path.join(__dirname, 'preload.js');
const rendererPath = path.join(__dirname, '..', 'renderer', 'index.html');
function confirmConnect(url: string): boolean {
function confirmConnect(url: string, _session: GatewaySession): boolean {
const lastUrl = settingsStore.getLastConnectedUrl();
if (lastUrl !== null && lastUrl === url) {
logger.info('Auto-approving connection from known URL', { url });