fix(ai-builder): Use expiring Computer Use setup tokens (no-changelog) (#29872)

This commit is contained in:
Albert Alises
2026-05-07 10:24:38 +02:00
committed by GitHub
parent 5e3aa1a726
commit be90f9f873
11 changed files with 274 additions and 24 deletions
@@ -270,8 +270,8 @@ Two options:
The static key is used for all requests — no pairing/session upgrade.
- **Dynamic (pairing → session key)**:
1. `POST /instance-ai/gateway/create-link` (requires session auth) →
returns `{ token, command }`. The token is a **one-time pairing token**
(5-min TTL).
returns `{ token, command, expiresAt, ttlSeconds }`. The token is a
**one-time pairing token** (5-min TTL).
2. Daemon calls `POST /instance-ai/gateway/init` with the pairing token →
server consumes the token and returns `{ ok: true, sessionKey }`.
3. All subsequent requests (SSE, response) use the **session key** instead
@@ -289,6 +289,8 @@ create-link → pairingToken (5 min TTL, single-use)
This prevents token replay: the pairing token is visible in terminal output
and `ps aux`, but it becomes useless after the first successful `init` call.
The resulting session key has no time-based expiry and remains valid until
explicit disconnect/revocation.
All key comparisons use `timingSafeEqual()` to prevent timing attacks.
---
@@ -855,8 +855,14 @@ describe('InstanceAiController', () => {
});
});
it('should return token and command', async () => {
it('should return token, command, and token expiry', async () => {
const nowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(new Date('2026-01-01T00:00:00.000Z').getTime());
instanceAiService.generatePairingToken.mockReturnValue('pairing-token');
instanceAiService.getGatewayApiKeyExpiresAt.mockReturnValue(
new Date('2026-01-01T00:05:00.000Z'),
);
urlService.getInstanceBaseUrl.mockReturnValue('https://myinstance.n8n.cloud');
const result = await controller.createGatewayLink(req);
@@ -864,8 +870,15 @@ describe('InstanceAiController', () => {
expect(result).toEqual({
token: 'pairing-token',
command: 'npx @n8n/computer-use https://myinstance.n8n.cloud pairing-token',
expiresAt: '2026-01-01T00:05:00.000Z',
ttlSeconds: 300,
});
expect(instanceAiService.generatePairingToken).toHaveBeenCalledWith(USER_ID);
expect(instanceAiService.getGatewayApiKeyExpiresAt).toHaveBeenCalledWith(
USER_ID,
'pairing-token',
);
nowSpy.mockRestore();
});
});
@@ -30,11 +30,15 @@ describe('LocalGatewayRegistry — per-user gateway isolation', () => {
expect(token1).toBe(token2);
});
it('returns the active session key if one already exists', () => {
it('returns a pairing token instead of exposing an active session key', () => {
const pairingToken = registry.generatePairingToken('user-a');
const sessionKey = registry.consumePairingToken('user-a', pairingToken);
const sessionKey = registry.consumePairingToken('user-a', pairingToken)!;
const nextPairingToken = registry.generatePairingToken('user-a');
expect(registry.generatePairingToken('user-a')).toBe(sessionKey);
expect(nextPairingToken).toMatch(/^gw_/);
expect(nextPairingToken).not.toBe(sessionKey);
expect(registry.getUserIdForApiKey(sessionKey)).toBe('user-a');
expect(registry.getUserIdForApiKey(nextPairingToken)).toBe('user-a');
});
it('generates independent tokens for different users', () => {
@@ -77,6 +81,16 @@ describe('LocalGatewayRegistry — per-user gateway isolation', () => {
});
describe('getPairingToken', () => {
it('returns the expiry time for an active pairing token', () => {
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000);
const token = registry.generatePairingToken('user-a');
expect(registry.getApiKeyExpiresAt('user-a', token)?.toISOString()).toBe(
new Date(301_000).toISOString(),
);
nowSpy.mockRestore();
});
it('returns null and cleans up the reverse lookup for an expired token', () => {
const token = registry.generatePairingToken('user-a');
@@ -91,6 +105,19 @@ describe('LocalGatewayRegistry — per-user gateway isolation', () => {
expect(registry.getPairingToken('user-a')).toBeNull();
expect(registry.getUserIdForApiKey(token)).toBeUndefined();
});
it('rejects an expired pairing token via getUserIdForApiKey without prior cleanup', () => {
const token = registry.generatePairingToken('user-a');
const userGateways = (
registry as unknown as {
userGateways: Map<string, { pairingToken: { token: string; createdAt: number } | null }>;
}
).userGateways;
userGateways.get('user-a')!.pairingToken!.createdAt = Date.now() - 10 * 60 * 1000;
expect(registry.getUserIdForApiKey(token)).toBeUndefined();
});
});
describe('getGatewayStatus', () => {
@@ -55,15 +55,23 @@ export class LocalGatewayRegistry {
/** Resolve an API key (pairing token or session key) back to the owning userId. */
getUserIdForApiKey(key: string): string | undefined {
return this.apiKeyToUserId.get(key);
const userId = this.apiKeyToUserId.get(key);
if (!userId) return undefined;
const state = this.userGateways.get(userId);
if (state?.pairingToken?.token === key) {
if (Date.now() - state.pairingToken.createdAt > PAIRING_TOKEN_TTL_MS) {
this.apiKeyToUserId.delete(state.pairingToken.token);
state.pairingToken = null;
return undefined;
}
}
return userId;
}
/** Generate a one-time pairing token for UI-initiated connections. */
generatePairingToken(userId: string): string {
const state = this.getOrCreate(userId);
// If there's an active session key, return it so the daemon can reconnect
// without losing its authenticated session (e.g. after a page reload).
if (state.activeSessionKey) return state.activeSessionKey;
// Reuse existing valid token to prevent race conditions between concurrent callers.
const existing = this.getPairingToken(userId);
@@ -87,6 +95,15 @@ export class LocalGatewayRegistry {
return state.pairingToken.token;
}
/** Get the expiry time for an active pairing token. Session keys do not expire. */
getApiKeyExpiresAt(userId: string, key: string): Date | null {
const state = this.userGateways.get(userId);
if (!state?.pairingToken || state.pairingToken.token !== key) return null;
const token = this.getPairingToken(userId);
if (!token) return null;
return new Date(state.pairingToken.createdAt + PAIRING_TOKEN_TTL_MS);
}
/**
* Consume the pairing token and issue a long-lived session key.
* Returns the session key, or null if the token is invalid or expired.
@@ -631,9 +631,13 @@ export class InstanceAiController {
async createGatewayLink(req: AuthenticatedRequest) {
await this.assertGatewayEnabled(req.user.id);
const token = this.instanceAiService.generatePairingToken(req.user.id);
const expiresAt = this.instanceAiService.getGatewayApiKeyExpiresAt(req.user.id, token);
const ttlSeconds = expiresAt
? Math.max(0, Math.ceil((expiresAt.getTime() - Date.now()) / 1000))
: null;
const baseUrl = this.urlService.getInstanceBaseUrl();
const command = `npx @n8n/computer-use ${baseUrl} ${token}`;
return { token, command };
return { token, command, expiresAt: expiresAt?.toISOString() ?? null, ttlSeconds };
}
@Get('/gateway/events', { usesTemplates: true, skipAuth: true })
@@ -1276,6 +1276,10 @@ export class InstanceAiService {
return this.gatewayRegistry.generatePairingToken(userId);
}
getGatewayApiKeyExpiresAt(userId: string, key: string): Date | null {
return this.gatewayRegistry.getApiKeyExpiresAt(userId, key);
}
getPairingToken(userId: string): string | null {
return this.gatewayRegistry.getPairingToken(userId);
}
@@ -5977,5 +5977,8 @@
"instanceAi.welcomeModal.gateway.instructions.mac": "Open Terminal (Cmd + Space, type \"Terminal\") and paste the command below.",
"instanceAi.welcomeModal.gateway.instructions.windows": "Open Terminal (Windows key, type \"Terminal\") and paste the command below.",
"instanceAi.welcomeModal.gateway.instructions.linux": "Open your terminal and paste the command below.",
"instanceAi.welcomeModal.gateway.tokenExpiresIn": "This token expires in {minutes} min.",
"instanceAi.welcomeModal.gateway.tokenExpired": "This token has expired. Copy the command again.",
"instanceAi.welcomeModal.gateway.leadingSpaceHint": "If your shell supports it, start the command with a space to keep it out of history.",
"instanceAi.welcomeModal.gateway.browserAutomationHint": "Want browser automation? Install the <a href=\"{url}\" target=\"_blank\" rel=\"noopener\">n8n Browser Use Chrome extension</a> so the agent can control your browser."
}
@@ -36,6 +36,8 @@ const mockFetchPreferences = vi.fn();
const mockUpdatePreferences = vi.fn();
const mockFetchModelCredentials = vi.fn().mockResolvedValue([]);
const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]);
const mockCreateGatewayLink = vi.fn();
const mockDisconnectGatewaySession = vi.fn();
vi.mock('../instanceAi.settings.api', () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
@@ -48,8 +50,8 @@ vi.mock('../instanceAi.settings.api', () => ({
const mockGetGatewayStatus = vi.fn();
vi.mock('../instanceAi.api', () => ({
createGatewayLink: vi.fn(),
disconnectGatewaySession: vi.fn(),
createGatewayLink: (...args: unknown[]) => mockCreateGatewayLink(...args),
disconnectGatewaySession: (...args: unknown[]) => mockDisconnectGatewaySession(...args),
getGatewayStatus: (...args: unknown[]) => mockGetGatewayStatus(...args),
}));
@@ -396,4 +398,86 @@ describe('useInstanceAiSettingsStore', () => {
expect(store.connections[0].type).toBe('computer-use');
});
});
describe('setup command', () => {
beforeEach(() => {
setModuleSettings(settingsStore, {
enabled: true,
localGatewayDisabled: false,
proxyEnabled: false,
optinModalDismissed: false,
cloudManaged: false,
});
setUserPreference(store, { localGatewayDisabled: false });
});
afterEach(() => {
vi.useRealTimers();
});
it('clears stale command state while fetching a new setup command', async () => {
let resolveRequest: (value: {
command: string;
expiresAt: string;
ttlSeconds: number;
}) => void = () => {};
mockCreateGatewayLink.mockReturnValue(
new Promise((resolve) => {
resolveRequest = resolve;
}),
);
store.setupCommand = 'old command';
store.setupCommandExpiresAt = '2026-01-01T00:00:00.000Z';
store.setupCommandTtlSeconds = 1;
store.setupCommandFetchedAt = 1;
const request = store.fetchSetupCommand();
expect(store.setupCommand).toBeNull();
expect(store.setupCommandExpiresAt).toBeNull();
expect(store.setupCommandTtlSeconds).toBeNull();
expect(store.setupCommandFetchedAt).toBeNull();
resolveRequest({
command: 'new command',
expiresAt: '2026-01-01T00:05:00.000Z',
ttlSeconds: 300,
});
await request;
expect(store.setupCommand).toBe('new command');
});
it('uses the request start time as setup command countdown baseline', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
mockCreateGatewayLink.mockImplementation(async () => {
vi.setSystemTime(new Date('2026-01-01T00:00:10.000Z'));
return {
command: 'command',
expiresAt: '2026-01-01T00:05:00.000Z',
ttlSeconds: 300,
};
});
await store.fetchSetupCommand();
expect(store.setupCommandFetchedAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime());
});
it('clears setup command state on disconnect', async () => {
mockDisconnectGatewaySession.mockResolvedValue(undefined);
store.setupCommand = 'old command';
store.setupCommandExpiresAt = '2026-01-01T00:00:00.000Z';
store.setupCommandTtlSeconds = 1;
store.setupCommandFetchedAt = 1;
await store.disconnectComputerUse();
expect(store.setupCommand).toBeNull();
expect(store.setupCommandExpiresAt).toBeNull();
expect(store.setupCommandTtlSeconds).toBeNull();
expect(store.setupCommandFetchedAt).toBeNull();
});
});
});
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { N8nHeading, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
@@ -46,6 +46,29 @@ const osTabs = [
];
const displayCommand = computed(() => store.setupCommand ?? 'npx @n8n/computer-use');
const canCopyCommand = computed(() => store.setupCommand !== null);
const nowMs = ref(Date.now());
let expiryTimer: ReturnType<typeof setInterval> | null = null;
const tokenExpiresInSeconds = computed(() => {
if (store.setupCommandTtlSeconds !== null && store.setupCommandFetchedAt !== null) {
const elapsedSeconds = Math.floor((nowMs.value - store.setupCommandFetchedAt) / 1000);
return Math.max(0, store.setupCommandTtlSeconds - elapsedSeconds);
}
return null;
});
const tokenExpiryText = computed(() => {
if (tokenExpiresInSeconds.value === null) return null;
if (tokenExpiresInSeconds.value === 0) {
return i18n.baseText('instanceAi.welcomeModal.gateway.tokenExpired');
}
const minutes = Math.max(1, Math.ceil(tokenExpiresInSeconds.value / 60));
return i18n.baseText('instanceAi.welcomeModal.gateway.tokenExpiresIn', {
interpolate: { minutes: String(minutes) },
});
});
const terminalInstructionsKey = computed(() => {
if (selectedOs.value === 'windows') return 'instanceAi.welcomeModal.gateway.instructions.windows';
@@ -112,7 +135,11 @@ function onCommandScroll(e: Event) {
async function copyCommand() {
try {
await navigator.clipboard.writeText(displayCommand.value);
if (tokenExpiresInSeconds.value === 0) {
await store.fetchSetupCommand();
}
if (!store.setupCommand) return;
await navigator.clipboard.writeText(store.setupCommand);
copied.value = true;
setTimeout(() => {
copied.value = false;
@@ -127,6 +154,27 @@ async function copyCommand() {
onMounted(() => {
void store.fetchSetupCommand();
});
watch(
() => [store.setupCommandFetchedAt, store.setupCommandTtlSeconds] as const,
([fetchedAt, ttlSeconds]) => {
if (expiryTimer) {
clearInterval(expiryTimer);
expiryTimer = null;
}
if (!(fetchedAt !== null && ttlSeconds !== null)) return;
nowMs.value = Date.now();
expiryTimer = setInterval(() => {
nowMs.value = Date.now();
}, 1000);
},
{ immediate: true },
);
onBeforeUnmount(() => {
if (expiryTimer) clearInterval(expiryTimer);
store.clearSetupCommand();
});
</script>
<template>
@@ -205,9 +253,18 @@ onMounted(() => {
:class="$style.copyButton"
:aria-label="copyCommandAriaLabel"
data-test-id="computer-use-setup-copy-command"
:disabled="!canCopyCommand"
@click="copyCommand"
/>
</div>
<div :class="$style.commandMeta">
<N8nText v-if="tokenExpiryText" size="small" color="text-light">
{{ tokenExpiryText }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('instanceAi.welcomeModal.gateway.leadingSpaceHint') }}
</N8nText>
</div>
<div :class="$style.waitingRow">
<N8nIcon icon="spinner" color="primary" spin size="small" />
<span>{{ i18n.baseText('instanceAi.welcomeModal.gateway.waiting') }}</span>
@@ -300,6 +357,14 @@ onMounted(() => {
background: var(--color--background--shade-2);
}
.commandMeta {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
padding: 0 var(--spacing--xs) var(--spacing--xs);
background: var(--color--background--shade-2);
}
.commandText {
color: var(--color--text--tint-1);
white-space: nowrap;
@@ -115,17 +115,21 @@ export async function getInstanceAiCredits(
}
/**
* POST /instance-ai/gateway/create-link -> { token, command }
* POST /instance-ai/gateway/create-link -> { token, command, expiresAt, ttlSeconds }
* Generate a dynamic gateway token and pre-built CLI command.
*/
export async function createGatewayLink(
context: IRestApiContext,
): Promise<{ token: string; command: string }> {
return await makeRestApiRequest<{ token: string; command: string }>(
context,
'POST',
'/instance-ai/gateway/create-link',
);
export async function createGatewayLink(context: IRestApiContext): Promise<{
token: string;
command: string;
expiresAt: string | null;
ttlSeconds: number | null;
}> {
return await makeRestApiRequest<{
token: string;
command: string;
expiresAt: string | null;
ttlSeconds: number | null;
}>(context, 'POST', '/instance-ai/gateway/create-link');
}
/**
@@ -45,6 +45,10 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const HAS_CONNECTED_STORAGE_KEY = 'instanceAi.gateway.hasConnected';
const isDaemonConnecting = ref(false);
const setupCommand = ref<string | null>(null);
const setupCommandExpiresAt = ref<string | null>(null);
const setupCommandTtlSeconds = ref<number | null>(null);
const setupCommandFetchedAt = ref<number | null>(null);
let setupCommandRequestId = 0;
const hasEverConnectedGateway = ref(
typeof localStorage !== 'undefined' &&
@@ -297,6 +301,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
);
return;
}
clearSetupCommand();
clearGatewayEverConnected();
gatewayConnected.value = false;
gatewayToolCategories.value = [];
@@ -451,11 +456,29 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
}
}
function clearSetupCommand(): void {
setupCommandRequestId++;
setupCommand.value = null;
setupCommandExpiresAt.value = null;
setupCommandTtlSeconds.value = null;
setupCommandFetchedAt.value = null;
}
async function fetchSetupCommand(): Promise<void> {
const requestId = ++setupCommandRequestId;
setupCommand.value = null;
setupCommandExpiresAt.value = null;
setupCommandTtlSeconds.value = null;
setupCommandFetchedAt.value = null;
if (isLocalGatewayDisabled.value) return;
const requestStartedAt = Date.now();
try {
const result = await createGatewayLink(rootStore.restApiContext);
if (requestId !== setupCommandRequestId) return;
setupCommand.value = result.command;
setupCommandExpiresAt.value = result.expiresAt;
setupCommandTtlSeconds.value = result.ttlSeconds;
setupCommandFetchedAt.value = requestStartedAt;
} catch {
// Fallback handled in the component
}
@@ -512,6 +535,9 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
// Gateway / daemon
isDaemonConnecting,
setupCommand,
setupCommandExpiresAt,
setupCommandTtlSeconds,
setupCommandFetchedAt,
hasEverConnectedGateway,
isGatewayConnected,
gatewayStatusLoaded,
@@ -529,6 +555,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
startGatewayPushListener,
stopGatewayPushListener,
fetchSetupCommand,
clearSetupCommand,
refreshCredentials,
refreshModuleSettings,
// Sidebar connections