mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat: Implement session based permission modes in Computer Use (#28184)
This commit is contained in:
parent
6f722efef3
commit
d3e6519730
15 changed files with 1501 additions and 353 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
422
packages/@n8n/computer-use/src/daemon.test.ts
Normal file
422
packages/@n8n/computer-use/src/daemon.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
|
|
|
|||
283
packages/@n8n/computer-use/src/gateway-session.test.ts
Normal file
283
packages/@n8n/computer-use/src/gateway-session.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
142
packages/@n8n/computer-use/src/gateway-session.ts
Normal file
142
packages/@n8n/computer-use/src/gateway-session.ts
Normal 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>;
|
||||
}
|
||||
211
packages/@n8n/computer-use/src/settings-store.test.ts
Normal file
211
packages/@n8n/computer-use/src/settings-store.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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), {
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue