mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
fix(ai-builder): Use expiring Computer Use setup tokens (no-changelog) (#29872)
This commit is contained in:
@@ -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."
|
||||
}
|
||||
|
||||
+86
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+67
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user