From aece9819940df5e7acfff7da6c003498e54922a5 Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Fri, 28 Aug 2026 09:42:39 -0700 Subject: [PATCH] fix: Use the connected key for the rest of the run The analyze step re-read the key with findExistingApiKey, which returns process.env.SEAM_API_KEY ahead of any dotenv file. A run that connected through the browser or a pasted key therefore planned the integration with whatever SEAM_API_KEY the shell happened to export, not the key it had just verified. When those keys belong to different workspaces the run does not fail: it reports the workspace it connected to and then works against the other one. It only surfaces as an error when the exported key is invalid, as "Couldn't start the AI session". Carry the verified key through settleOn instead. The browser and pasted paths called setWorkspace and advanceAfterAuth directly, bypassing the one place that records a connection, which is why the 'browser' and 'pasted' members of its source union were unreachable. Routing them through settleOn fixes the key selection and records those runs. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/app.tsx | 19 ++++++---- src/lib/steps/authenticate.test.ts | 58 ++++++++++++++++++++++++++++++ src/lib/steps/authenticate.ts | 3 +- src/lib/steps/connect-web.ts | 3 +- 4 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 src/lib/steps/authenticate.test.ts diff --git a/src/lib/app.tsx b/src/lib/app.tsx index fdd9742..375607a 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -121,6 +121,9 @@ export function App({ const { isRawModeSupported } = useStdin() const projectRef = useRef(detectProject(root)) const attemptRef = useRef(0) + // The key this run actually connected with. Downstream steps read this + // instead of the environment, which may hold a key for another workspace. + const apiKeyRef = useRef(null) const [dimensions, setDimensions] = useState({ rows: stdout?.rows ?? 24, @@ -331,6 +334,7 @@ export function App({ source: 'project' | 'cli' | 'browser' | 'pasted', location: string | null, ): void => { + apiKeyRef.current = apiKey setWorkspace(settledWorkspace) saveConnection(root, { workspace: settledWorkspace, @@ -429,12 +433,11 @@ export function App({ !cancelled && setBrowser((b) => ({ ...b, received: true })), }) if (cancelled) return - setWorkspace(result.workspace) addMessage({ tone: 'ok', text: `Connected ยท workspace ${result.workspace.name}`, }) - advanceAfterAuth() + settleOn(result.workspace, result.api_key, 'browser', '.env') } catch (error) { if (!cancelled) { setPhase({ @@ -471,9 +474,8 @@ export function App({ try { const result = await verifyAndSaveKey(root, apiKey) if (cancelled) return - setWorkspace(result.workspace) addMessage({ tone: 'ok', text: `Workspace: ${result.workspace.name}` }) - advanceAfterAuth() + settleOn(result.workspace, result.api_key, 'pasted', '.env') } catch (error) { if (cancelled) return const message = @@ -610,8 +612,11 @@ export function App({ if (phase.t !== 'analyze') return let cancelled = false const run = async (): Promise => { - const found = findExistingApiKey(root) - if (found == null) { + // Prefer the key this run connected with: re-reading here would pick up + // SEAM_API_KEY, which can belong to a different workspace than the one + // just chosen, and silently plan the integration against that one. + const apiKey = apiKeyRef.current ?? findExistingApiKey(root)?.api_key + if (apiKey == null) { addMessage({ tone: 'warn', text: "Couldn't find your Seam API key to plan the integration.", @@ -622,7 +627,7 @@ export function App({ let currentSession: WizardInferenceSession try { - currentSession = await exchangeWizardInferenceToken(found.api_key) + currentSession = await exchangeWizardInferenceToken(apiKey) } catch (error) { if (!cancelled) { addMessage({ diff --git a/src/lib/steps/authenticate.test.ts b/src/lib/steps/authenticate.test.ts new file mode 100644 index 0000000..2869166 --- /dev/null +++ b/src/lib/steps/authenticate.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +const { get } = vi.hoisted(() => ({ get: vi.fn() })) + +vi.mock('@seamapi/http', () => ({ + isSeamHttpApiError: () => false, + isSeamHttpUnauthorizedError: () => false, + SeamHttpInvalidTokenError: class extends Error {}, + SeamHttpWorkspaces: class { + get = get + client = { post: vi.fn() } + }, +})) + +const { verifyAndSaveKey } = await import('./authenticate.js') + +const workspace = { + workspace_id: 'workspace-1', + name: 'Acme', + is_sandbox: false, +} + +let dir = '' + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'seam-wizard-')) + vi.clearAllMocks() +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +// The run has to carry the key it verified. Returning only the workspace left +// the caller to re-read SEAM_API_KEY, which may belong to another workspace. +test('verifyAndSaveKey returns the key it verified', async () => { + get.mockResolvedValue(workspace) + + const result = await verifyAndSaveKey(dir, 'seam_pasted_key') + + expect(result.workspace).toBe(workspace) + expect(result.api_key).toBe('seam_pasted_key') +}) + +test('verifyAndSaveKey returns the trimmed key it saved, not the raw input', async () => { + get.mockResolvedValue(workspace) + + const result = await verifyAndSaveKey(dir, ' seam_padded_key\n') + + expect(result.api_key).toBe('seam_padded_key') + expect(readFileSync(join(dir, '.env'), 'utf8')).toContain( + 'SEAM_API_KEY=seam_padded_key', + ) +}) diff --git a/src/lib/steps/authenticate.ts b/src/lib/steps/authenticate.ts index e192464..e8d807e 100644 --- a/src/lib/steps/authenticate.ts +++ b/src/lib/steps/authenticate.ts @@ -4,6 +4,7 @@ import { findExistingApiKey, saveProjectApiKey } from 'lib/env-file.js' export interface AuthResult { workspace: SeamWorkspace + api_key: string } export interface ExistingKeyResult { @@ -55,7 +56,7 @@ export async function verifyAndSaveKey( const trimmed = apiKey.trim() const workspace = await getWorkspaceForApiKey(trimmed) saveProjectApiKey(root, trimmed) - return { workspace } + return { workspace, api_key: trimmed } } export function saveVerifiedKey(root: string, apiKey: string): void { diff --git a/src/lib/steps/connect-web.ts b/src/lib/steps/connect-web.ts index 3dd19c1..d2307f7 100644 --- a/src/lib/steps/connect-web.ts +++ b/src/lib/steps/connect-web.ts @@ -14,6 +14,7 @@ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 export interface WebConnectResult { workspace: SeamWorkspace + api_key: string } // Progress callbacks so the Ink UI can render the handoff without any logging @@ -110,7 +111,7 @@ export async function connectViaWeb( const workspace = await getWorkspaceForApiKey(payload.api_key) saveProjectApiKey(root, payload.api_key) - return { workspace } + return { workspace, api_key: payload.api_key } } function respondJson(