diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index a0c811b295..1ad7396b38 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -171,6 +171,9 @@ management, and fleet control surfaces. path must also auto-create the scoped first-host install token so the operator lands on ready-to-copy commands instead of being asked to perform a second manual token-generation step immediately after securing the server. + Any first-run credentials download generated from that same handoff must + describe the prepared first-host token path consistently instead of telling + the operator to generate another install token manually. 9. Keep `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx` ordered around the actual first-run operator sequence: credentials that must be saved now should be visible before the operator leaves the screen, and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9c5c4497cd..323e8fb6ab 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -223,6 +223,10 @@ Own canonical runtime payload shapes between backend and frontend. token-creation contract must remain usable immediately from the install workspace so the first-host flow can auto-create the scoped install token without forcing the operator through a second manual token-generation step. + Any downloaded first-run handoff instructions emitted by that same shared + install-state surface must describe that prepared token path consistently + with the live runtime behavior rather than directing the operator to create + another install token manually. ## Current State diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx index 6f21eaa82d..665769ed67 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx @@ -26,6 +26,23 @@ import type { } from '@/types/api'; import { STORAGE_KEYS } from '@/utils/localStorage'; +const createObjectURLMock = vi.fn(() => 'blob:mock-url'); +const revokeObjectURLMock = vi.fn(); + +class MockBlob { + readonly parts: string[]; + readonly type: string; + + constructor(parts: unknown[], options?: { type?: string }) { + this.parts = parts.map((part) => String(part)); + this.type = options?.type || ''; + } + + async text() { + return this.parts.join(''); + } +} + const { navigateMock } = vi.hoisted(() => ({ navigateMock: vi.fn(), })); @@ -656,8 +673,15 @@ beforeEach(() => { headers: { 'Content-Type': 'application/json' }, }), ); + createObjectURLMock.mockReset().mockReturnValue('blob:mock-url'); + revokeObjectURLMock.mockReset(); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator); + vi.stubGlobal('Blob', MockBlob as unknown as typeof Blob); + vi.stubGlobal('URL', Object.assign(URL, { + createObjectURL: createObjectURLMock, + revokeObjectURL: revokeObjectURLMock, + })); listProfilesMock.mockResolvedValue([]); listAssignmentsMock.mockResolvedValue([]); @@ -721,6 +745,59 @@ describe('InfrastructureOperationsController token generation', () => { expect(screen.getByText('Installation commands')).toBeInTheDocument(); }); + it('downloads first-run credentials that describe the prepared install token handoff', async () => { + createTokenMock.mockResolvedValue({ + token: 'handoff-install-token', + record: { + id: 'token-record-handoff', + name: 'Agent 2026-03-27 11-02', + prefix: 'agent', + suffix: '1234', + createdAt: new Date().toISOString(), + }, + }); + + const anchorClickMock = vi.fn(); + const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName) => { + const element = document.createElementNS('http://www.w3.org/1999/xhtml', tagName); + if (tagName.toLowerCase() === 'a') { + Object.defineProperty(element, 'click', { + value: anchorClickMock, + configurable: true, + }); + } + return element as HTMLElement; + }); + + sessionStorage.setItem( + STORAGE_KEYS.SETUP_HANDOFF, + JSON.stringify({ + username: 'admin', + password: 'generated-password', + apiToken: 'admin-token', + createdAt: new Date().toISOString(), + }), + ); + + setupComponent(); + + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + fireEvent.click(screen.getByRole('button', { name: 'Download credentials' })); + + await waitFor(() => expect(createObjectURLMock).toHaveBeenCalled(), { interval: 0 }); + + const downloadedBlob = createObjectURLMock.mock.calls.at(-1)?.[0] as MockBlob | undefined; + expect(downloadedBlob).toBeDefined(); + const content = await downloadedBlob!.text(); + expect(content).toContain('Canonical install workspace:'); + expect(content).toContain('/settings/infrastructure/install'); + expect(content).toContain( + 'Pulse prepares the first-host install token from setup so you can move straight to the generated Unified Agent install commands.', + ); + + createElementSpy.mockRestore(); + }); + it('generates a token and shows confirmation', async () => { createTokenMock.mockResolvedValue({ token: 'test-token-123', diff --git a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx index add77c20a1..c0bf91824d 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx @@ -195,7 +195,7 @@ Canonical install workspace: ---------------------------- ${baseUrl.replace(/\/$/, '')}/settings/infrastructure/install -Generate a scoped install token below before copying Unified Agent install commands. +Pulse prepares the first-host install token from setup so you can move straight to the generated Unified Agent install commands. `; const blob = new Blob([content], { type: 'text/plain' });