diff --git a/docs/learnings/2026-04-25-ci-os-specific-path-assertion.md b/docs/learnings/2026-04-25-ci-os-specific-path-assertion.md new file mode 100644 index 0000000..7ddeda4 --- /dev/null +++ b/docs/learnings/2026-04-25-ci-os-specific-path-assertion.md @@ -0,0 +1,13 @@ +# CI OS-specific path assertion + +## What happened + +CI failed on `pi-agent-manager.test.ts` because a launch-command assertion expected the quoted agent binary path to contain `'/Users/`. That passed on macOS but failed on the Linux GitHub runner, where `os.homedir()` resolves to `/home/runner`. + +## Fix + +Assert against `posixShellQuote(mgr.getBinPath())` instead of a hard-coded platform-specific path prefix. This keeps the test focused on the intended behavior: command paths are shell-quoted. + +## Prevention + +Avoid hard-coding OS-specific home directory prefixes in cross-platform tests. When testing derived paths, assert against the same public path-building API or use platform-neutral path properties. diff --git a/docs/learnings/2026-04-25-provider-ordering-canonical-id.md b/docs/learnings/2026-04-25-provider-ordering-canonical-id.md new file mode 100644 index 0000000..2376a22 --- /dev/null +++ b/docs/learnings/2026-04-25-provider-ordering-canonical-id.md @@ -0,0 +1,3 @@ +# Provider Ordering Expectations With Canonical IDs + +When changing a provider identifier to match an upstream canonical id, update ordering tests to match the actual label/id sort behavior. `amazon-bedrock` sorts before `anthropic` with the existing `localeCompare`-based ordering, so tests should assert that order instead of preserving the previous `bedrock` position. diff --git a/src/main/__tests__/pi-agent-manager.test.ts b/src/main/__tests__/pi-agent-manager.test.ts index 794213a..19ca3e3 100644 --- a/src/main/__tests__/pi-agent-manager.test.ts +++ b/src/main/__tests__/pi-agent-manager.test.ts @@ -22,10 +22,10 @@ describe('posixShellQuote', () => { describe('PiAgentManager.buildLaunchCommand', () => { const mgr = new PiAgentManager(); - it('produces an empty envOverrides path starting with FLEET_BRIDGE_PORT', () => { + it('produces an empty envOverrides path starting with quoted Fleet env vars', () => { const cmd = mgr.buildLaunchCommand(8123, 'tok', 'pane-1', {}); expect( - cmd.startsWith('FLEET_BRIDGE_PORT=8123 FLEET_BRIDGE_TOKEN=tok FLEET_PANE_ID=pane-1 ') + cmd.startsWith("FLEET_BRIDGE_PORT='8123' FLEET_BRIDGE_TOKEN='tok' FLEET_PANE_ID='pane-1' ") ).toBe(true); }); @@ -35,13 +35,34 @@ describe('PiAgentManager.buildLaunchCommand', () => { AWS_SECRET_ACCESS_KEY: `it's/a/secret` }); expect(cmd).toMatch( - /^AWS_REGION='us-east-1' AWS_SECRET_ACCESS_KEY='it'\\''s\/a\/secret' FLEET_BRIDGE_PORT=/ + /^AWS_REGION='us-east-1' AWS_SECRET_ACCESS_KEY='it'\\''s\/a\/secret' FLEET_BRIDGE_PORT='8123'/ ); }); + it('quotes shell-sensitive Fleet vars and command paths for shell -c', () => { + const cmd = mgr.buildLaunchCommand(8123, "tok'; $(touch nope)", 'pane;`id`', {}); + + expect(cmd).toContain("FLEET_BRIDGE_TOKEN='tok'\\''; $(touch nope)'"); + expect(cmd).toContain("FLEET_PANE_ID='pane;`id`'"); + expect(cmd).toContain(posixShellQuote(mgr.getBinPath())); + expect(cmd).toContain(" -e '"); + expect(cmd).not.toContain('"'); + }); + it('serializes envOverrides in stable insertion order', () => { const cmd = mgr.buildLaunchCommand(0, '', '', { A: '1', B: '2', C: '3' }); expect(cmd.indexOf(`A='1'`)).toBeLessThan(cmd.indexOf(`B='2'`)); expect(cmd.indexOf(`B='2'`)).toBeLessThan(cmd.indexOf(`C='3'`)); }); + + it('prepends unset directives before env assignments', () => { + const cmd = mgr.buildLaunchCommand(8123, 'tok', 'pane-1', { AWS_PROFILE: 'dev' }, [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY' + ]); + + expect(cmd.startsWith("unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; AWS_PROFILE='dev' ")).toBe( + true + ); + }); }); diff --git a/src/main/__tests__/pi-auth-inspector.test.ts b/src/main/__tests__/pi-auth-inspector.test.ts index c5f198c..2c13e32 100644 --- a/src/main/__tests__/pi-auth-inspector.test.ts +++ b/src/main/__tests__/pi-auth-inspector.test.ts @@ -77,3 +77,39 @@ describe('PiAuthInspector.getBuiltInStatus', () => { expect(list.every((p) => !p.authenticated || p.method === 'env-var')).toBe(true); }); }); + +describe('PiAuthInspector.listAvailableModels', () => { + let dir: string; + + beforeEach(() => { + dir = makeDir(); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('loads models from Pi public getProviders/getModels exports', async () => { + const modulePath = join(dir, 'pi-ai.mjs'); + writeFileSync( + modulePath, + `export function getProviders() { return ['anthropic', 'amazon-bedrock']; } +export function getModels(provider) { + if (provider === 'anthropic') return [{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' }]; + if (provider === 'amazon-bedrock') return [{ id: 'amazon.nova-pro-v1:0', name: 'Nova Pro' }]; + return []; +} +` + ); + + const insp = new PiAuthInspector({ + authPath: join(dir, 'auth.json'), + modelCatalogPath: modulePath + }); + + await expect(insp.listAvailableModels()).resolves.toEqual([ + { providerId: 'anthropic', modelId: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, + { providerId: 'amazon-bedrock', modelId: 'amazon.nova-pro-v1:0', label: 'Nova Pro' } + ]); + }); +}); diff --git a/src/main/__tests__/pi-env-injection-manager.test.ts b/src/main/__tests__/pi-env-injection-manager.test.ts index 08626a9..915b2e8 100644 --- a/src/main/__tests__/pi-env-injection-manager.test.ts +++ b/src/main/__tests__/pi-env-injection-manager.test.ts @@ -34,8 +34,8 @@ describe('PiBedrockInjectionSchema', () => { expect(parsed.mode).toBe('chain'); }); - it('accepts all three modes', () => { - for (const mode of ['profile', 'keys', 'chain'] as const) { + it('accepts all credential modes', () => { + for (const mode of ['profile', 'keys', 'bearer', 'chain'] as const) { expect(PiBedrockInjectionSchema.parse({ mode }).mode).toBe(mode); } }); @@ -67,10 +67,21 @@ describe('PiEnvInjectionManager — writeBedrock/getRedactedConfig', () => { region: 'us-west-2', accessKeyId: 'AKIA…', secretAccessKeyPresent: true, - sessionTokenPresent: false + sessionTokenPresent: false, + bearerTokenPresent: false }); }); + it('round-trips bearer token presence without exposing plaintext', () => { + const store = new FakeStore(); + const mgr = new PiEnvInjectionManager({ store, safeStorage: fakeSafeStorage }); + + mgr.writeBedrock({ mode: 'bearer', bearerToken: 'bearer-secret' }); + + expect(mgr.getRedactedConfig().bedrock?.bearerTokenPresent).toBe(true); + expect(JSON.stringify(store.get())).not.toContain('bearer-secret'); + }); + it('encrypts secrets before persisting', () => { const store = new FakeStore(); const mgr = new PiEnvInjectionManager({ store, safeStorage: fakeSafeStorage }); @@ -98,6 +109,16 @@ describe('PiEnvInjectionManager — writeBedrock/getRedactedConfig', () => { expect(redacted?.sessionTokenPresent).toBe(true); }); + it('clearBedrockSecret removes bearer token only when requested', () => { + const store = new FakeStore(); + const mgr = new PiEnvInjectionManager({ store, safeStorage: fakeSafeStorage }); + + mgr.writeBedrock({ mode: 'bearer', bearerToken: 'bt' }); + mgr.clearBedrockSecret('bearerToken'); + + expect(mgr.getRedactedConfig().bedrock?.bearerTokenPresent).toBe(false); + }); + it('write with safeStorage unavailable throws when secrets are supplied', () => { const store = new FakeStore(); const mgr = new PiEnvInjectionManager({ @@ -106,6 +127,7 @@ describe('PiEnvInjectionManager — writeBedrock/getRedactedConfig', () => { }); expect(() => mgr.writeBedrock({ mode: 'keys', secretAccessKey: 'x' })).toThrow(/encryption/i); + expect(() => mgr.writeBedrock({ mode: 'bearer', bearerToken: 'x' })).toThrow(/encryption/i); }); it('write with safeStorage unavailable succeeds when no secrets are supplied', () => { @@ -130,20 +152,28 @@ describe('PiEnvInjectionManager.getInjectedEnv', () => { it('mode=chain writes only AWS_REGION when present', () => { const mgr = buildMgr({ mode: 'chain', region: 'eu-central-1' }); - expect(mgr.getInjectedEnv()).toEqual({ AWS_REGION: 'eu-central-1' }); + expect(mgr.getInjectedEnv()).toEqual({ set: { AWS_REGION: 'eu-central-1' }, unset: [] }); }); it('mode=chain with no region writes nothing', () => { const mgr = buildMgr({ mode: 'chain' }); - expect(mgr.getInjectedEnv()).toEqual({}); + expect(mgr.getInjectedEnv()).toEqual({ set: {}, unset: [] }); }); - it('mode=profile writes AWS_PROFILE + AWS_REGION', () => { + it('mode=profile writes AWS_PROFILE + AWS_REGION and unsets key credentials', () => { const mgr = buildMgr({ mode: 'profile', profile: 'dev', region: 'us-east-1' }); - expect(mgr.getInjectedEnv()).toEqual({ AWS_PROFILE: 'dev', AWS_REGION: 'us-east-1' }); + expect(mgr.getInjectedEnv()).toEqual({ + set: { AWS_PROFILE: 'dev', AWS_REGION: 'us-east-1' }, + unset: [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_BEARER_TOKEN_BEDROCK' + ] + }); }); - it('mode=keys decrypts secretAccessKey and sessionToken', () => { + it('mode=keys decrypts secretAccessKey and sessionToken and unsets profile/bearer', () => { const store = new FakeStore(); const mgr = new PiEnvInjectionManager({ store, safeStorage: fakeSafeStorage }); mgr.writeBedrock({ @@ -154,10 +184,24 @@ describe('PiEnvInjectionManager.getInjectedEnv', () => { sessionToken: 'tok' }); expect(mgr.getInjectedEnv()).toEqual({ - AWS_REGION: 'us-east-1', - AWS_ACCESS_KEY_ID: 'AKIA', - AWS_SECRET_ACCESS_KEY: 'shh', - AWS_SESSION_TOKEN: 'tok' + set: { + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'AKIA', + AWS_SECRET_ACCESS_KEY: 'shh', + AWS_SESSION_TOKEN: 'tok' + }, + unset: ['AWS_PROFILE', 'AWS_BEARER_TOKEN_BEDROCK'] + }); + }); + + it('mode=bearer decrypts AWS_BEARER_TOKEN_BEDROCK and unsets profile/key credentials', () => { + const store = new FakeStore(); + const mgr = new PiEnvInjectionManager({ store, safeStorage: fakeSafeStorage }); + mgr.writeBedrock({ mode: 'bearer', region: 'us-east-1', bearerToken: 'bt' }); + + expect(mgr.getInjectedEnv()).toEqual({ + set: { AWS_REGION: 'us-east-1', AWS_BEARER_TOKEN_BEDROCK: 'bt' }, + unset: ['AWS_PROFILE', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN'] }); }); @@ -172,6 +216,9 @@ describe('PiEnvInjectionManager.getInjectedEnv', () => { } }); const mgr = new PiEnvInjectionManager({ store, safeStorage: fakeSafeStorage }); - expect(mgr.getInjectedEnv()).toEqual({ AWS_REGION: 'us-east-1', AWS_ACCESS_KEY_ID: 'AKIA' }); + expect(mgr.getInjectedEnv()).toEqual({ + set: { AWS_REGION: 'us-east-1', AWS_ACCESS_KEY_ID: 'AKIA' }, + unset: ['AWS_PROFILE', 'AWS_BEARER_TOKEN_BEDROCK'] + }); }); }); diff --git a/src/main/index.ts b/src/main/index.ts index da1b277..69df542 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -72,8 +72,8 @@ const piAuthInspector = new PiAuthInspector({ 'node_modules', '@mariozechner', 'pi-ai', - 'src', - 'models.generated.ts' + 'dist', + 'index.js' ) }); const fleetBridge = new FleetBridgeServer(); diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index ac159bb..668596c 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -48,7 +48,7 @@ import type { PiConfigManager } from './pi-config-manager'; import { PiConfigParseError, PiConfigValidationError } from './pi-config-manager'; import type { PiAuthInspector } from './pi-auth-inspector'; import type { PiEnvInjectionManager } from './pi-env-injection-manager'; -import type { BedrockWritePatch } from '../shared/pi-env-injection-types'; +import type { BedrockWritePatch, BedrockSecretField } from '../shared/pi-env-injection-types'; import type { PiProvider, PiSettings } from '../shared/pi-config-types'; import type { FleetSettings } from '../shared/types'; import { checkSystemDeps } from './system-checker'; @@ -539,7 +539,7 @@ export function registerIpcHandlers( const token = fleetBridge.generateToken(); const port = fleetBridge.getPort(); const env = piEnvInjectionManager.getInjectedEnv(); - const cmd = piAgentManager.buildLaunchCommand(port, token, req.paneId, env); + const cmd = piAgentManager.buildLaunchCommand(port, token, req.paneId, env.set, env.unset); return { cmd }; }); @@ -647,7 +647,7 @@ export function registerIpcHandlers( ipcMain.handle( IPC_CHANNELS.PI_ENV_CLEAR_SECRET, - (_event, field: 'secretAccessKey' | 'sessionToken') => { + (_event, field: BedrockSecretField) => { piEnvInjectionManager.clearBedrockSecret(field); } ); diff --git a/src/main/pi-agent-manager.ts b/src/main/pi-agent-manager.ts index 600d960..53ffbf4 100644 --- a/src/main/pi-agent-manager.ts +++ b/src/main/pi-agent-manager.ts @@ -98,32 +98,33 @@ export class PiAgentManager { bridgePort: number, bridgeToken: string, paneId: string, - envOverrides: Record = {} + envOverrides: Record = {}, + envUnsets: string[] = [] ): string { const extensionPaths = this.getExtensionPaths(); const parts: string[] = []; + if (envUnsets.length > 0) { + parts.push(`unset ${envUnsets.join(' ')};`); + } + for (const [key, value] of Object.entries(envOverrides)) { parts.push(`${key}=${posixShellQuote(value)}`); } - parts.push(`FLEET_BRIDGE_PORT=${bridgePort}`); - parts.push(`FLEET_BRIDGE_TOKEN=${bridgeToken}`); - parts.push(`FLEET_PANE_ID=${paneId}`); + parts.push(`FLEET_BRIDGE_PORT=${posixShellQuote(String(bridgePort))}`); + parts.push(`FLEET_BRIDGE_TOKEN=${posixShellQuote(bridgeToken)}`); + parts.push(`FLEET_PANE_ID=${posixShellQuote(paneId)}`); - parts.push(this.quoteArg(this.getBinPath())); + parts.push(posixShellQuote(this.getBinPath())); for (const ext of extensionPaths) { - parts.push('-e', this.quoteArg(ext)); + parts.push('-e', posixShellQuote(ext)); } return parts.join(' '); } - private quoteArg(arg: string): string { - return arg.includes(' ') ? `"${arg}"` : arg; - } - async ensureInstalled(): Promise { if (this.isInstalled()) return; diff --git a/src/main/pi-auth-inspector.ts b/src/main/pi-auth-inspector.ts index 22a8da6..5e79a08 100644 --- a/src/main/pi-auth-inspector.ts +++ b/src/main/pi-auth-inspector.ts @@ -1,5 +1,6 @@ import { readFile } from 'fs/promises'; import { join } from 'path'; +import { pathToFileURL } from 'url'; import { homedir } from 'os'; import { z } from 'zod'; import { createLogger } from './logger'; @@ -10,12 +11,15 @@ const log = createLogger('pi-auth-inspector'); const AuthMapSchema = z.record(z.string(), z.unknown()); -const ModelCatalogItemSchema = z.object({ - provider: z.string(), +const PublicModelSchema = z.object({ id: z.string(), name: z.string().optional() }); -const ModelCatalogSchema = z.array(z.unknown()); + +type PiModelCatalogModule = { + getProviders?: unknown; + getModels?: unknown; +}; type PiAuthInspectorOptions = { authPath?: string; @@ -70,19 +74,28 @@ export class PiAuthInspector { async listAvailableModels(): Promise { if (!this.modelCatalogPath) return []; try { - const text = await readFile(this.modelCatalogPath, 'utf-8'); - const match = text.match(/MODELS\s*=\s*(\[[\s\S]*?\]);/); - if (!match) return []; - const rawArray = ModelCatalogSchema.parse(JSON.parse(match[1])); + const module = (await import(pathToFileURL(this.modelCatalogPath).href)) as PiModelCatalogModule; + if (typeof module.getProviders !== 'function' || typeof module.getModels !== 'function') { + return []; + } + + const providersRaw: unknown = module.getProviders(); + if (!Array.isArray(providersRaw)) return []; + const results: ModelEntry[] = []; - for (const raw of rawArray) { - const item = ModelCatalogItemSchema.safeParse(raw); - if (!item.success) continue; - results.push({ - providerId: item.data.provider, - modelId: item.data.id, - label: item.data.name ?? item.data.id - }); + for (const provider of providersRaw) { + if (typeof provider !== 'string') continue; + const modelsRaw: unknown = module.getModels(provider); + if (!Array.isArray(modelsRaw)) continue; + for (const raw of modelsRaw) { + const item = PublicModelSchema.safeParse(raw); + if (!item.success) continue; + results.push({ + providerId: provider, + modelId: item.data.id, + label: item.data.name ?? item.data.id + }); + } } return results; } catch (err) { diff --git a/src/main/pi-env-injection-manager.ts b/src/main/pi-env-injection-manager.ts index 105a2e5..160faae 100644 --- a/src/main/pi-env-injection-manager.ts +++ b/src/main/pi-env-injection-manager.ts @@ -5,7 +5,8 @@ import { type PiEnvInjection, type PiBedrockInjection, type RedactedBedrock, - type BedrockWritePatch + type BedrockWritePatch, + type BedrockSecretField } from '../shared/pi-env-injection-types'; import { createLogger } from './logger'; @@ -13,6 +14,25 @@ const log = createLogger('pi-env-injection'); export type { BedrockWritePatch }; +export type InjectedEnv = { + set: Record; + unset: string[]; +}; + +const PROFILE_MODE_UNSETS = [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_BEARER_TOKEN_BEDROCK' +]; +const KEYS_MODE_UNSETS = ['AWS_PROFILE', 'AWS_BEARER_TOKEN_BEDROCK']; +const BEARER_MODE_UNSETS = [ + 'AWS_PROFILE', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN' +]; + interface EnvInjectionStore { get(): PiEnvInjection; set(next: PiEnvInjection): void; @@ -52,22 +72,25 @@ export class PiEnvInjectionManager { getRedactedConfig(): { bedrock?: RedactedBedrock } { const raw = PiEnvInjectionSchema.parse(this.store.get()); if (!raw.bedrock) return {}; + const bedrock: RedactedBedrock = { + mode: raw.bedrock.mode, + secretAccessKeyPresent: Boolean(raw.bedrock.secretAccessKeyEnc), + sessionTokenPresent: Boolean(raw.bedrock.sessionTokenEnc), + bearerTokenPresent: Boolean(raw.bedrock.bearerTokenEnc) + }; + if (raw.bedrock.region) bedrock.region = raw.bedrock.region; + if (raw.bedrock.profile) bedrock.profile = raw.bedrock.profile; + if (raw.bedrock.accessKeyId) bedrock.accessKeyId = raw.bedrock.accessKeyId; return { - bedrock: { - mode: raw.bedrock.mode, - region: raw.bedrock.region, - profile: raw.bedrock.profile, - accessKeyId: raw.bedrock.accessKeyId, - secretAccessKeyPresent: Boolean(raw.bedrock.secretAccessKeyEnc), - sessionTokenPresent: Boolean(raw.bedrock.sessionTokenEnc) - } + bedrock }; } writeBedrock(patch: BedrockWritePatch): void { const suppliesSecret = (patch.secretAccessKey !== undefined && patch.secretAccessKey !== '') || - (patch.sessionToken !== undefined && patch.sessionToken !== ''); + (patch.sessionToken !== undefined && patch.sessionToken !== '') || + (patch.bearerToken !== undefined && patch.bearerToken !== ''); if (suppliesSecret && !this.safe.isEncryptionAvailable()) { throw new Error('OS keychain encryption is unavailable; cannot store AWS secret.'); } @@ -82,7 +105,8 @@ export class PiEnvInjectionManager { accessKeyId: patch.accessKeyId !== undefined ? patch.accessKeyId || undefined : current.accessKeyId, secretAccessKeyEnc: current.secretAccessKeyEnc, - sessionTokenEnc: current.sessionTokenEnc + sessionTokenEnc: current.sessionTokenEnc, + bearerTokenEnc: current.bearerTokenEnc }; if (patch.secretAccessKey !== undefined) { @@ -97,35 +121,44 @@ export class PiEnvInjectionManager { ? undefined : this.safe.encryptString(patch.sessionToken).toString('base64'); } + if (patch.bearerToken !== undefined) { + next.bearerTokenEnc = + patch.bearerToken === '' + ? undefined + : this.safe.encryptString(patch.bearerToken).toString('base64'); + } this.store.set({ ...raw, bedrock: next }); } - clearBedrockSecret(field: 'secretAccessKey' | 'sessionToken'): void { + clearBedrockSecret(field: BedrockSecretField): void { const raw = PiEnvInjectionSchema.parse(this.store.get()); if (!raw.bedrock) return; const next: PiBedrockInjection = { ...raw.bedrock }; if (field === 'secretAccessKey') next.secretAccessKeyEnc = undefined; if (field === 'sessionToken') next.sessionTokenEnc = undefined; + if (field === 'bearerToken') next.bearerTokenEnc = undefined; this.store.set({ ...raw, bedrock: next }); } /** Main-process only. Decrypts on demand; skips fields that fail to decrypt. */ - getInjectedEnv(): Record { + getInjectedEnv(): InjectedEnv { const raw = PiEnvInjectionSchema.parse(this.store.get()); - const out: Record = {}; + const out: InjectedEnv = { set: {}, unset: [] }; const b = raw.bedrock; if (!b) return out; - if (b.region) out.AWS_REGION = b.region; + if (b.region) out.set.AWS_REGION = b.region; if (b.mode === 'profile') { - if (b.profile) out.AWS_PROFILE = b.profile; + out.unset = PROFILE_MODE_UNSETS; + if (b.profile) out.set.AWS_PROFILE = b.profile; } else if (b.mode === 'keys') { - if (b.accessKeyId) out.AWS_ACCESS_KEY_ID = b.accessKeyId; + out.unset = KEYS_MODE_UNSETS; + if (b.accessKeyId) out.set.AWS_ACCESS_KEY_ID = b.accessKeyId; if (b.secretAccessKeyEnc) { try { - out.AWS_SECRET_ACCESS_KEY = this.safe.decryptString( + out.set.AWS_SECRET_ACCESS_KEY = this.safe.decryptString( Buffer.from(b.secretAccessKeyEnc, 'base64') ); } catch (err) { @@ -136,13 +169,28 @@ export class PiEnvInjectionManager { } if (b.sessionTokenEnc) { try { - out.AWS_SESSION_TOKEN = this.safe.decryptString(Buffer.from(b.sessionTokenEnc, 'base64')); + out.set.AWS_SESSION_TOKEN = this.safe.decryptString( + Buffer.from(b.sessionTokenEnc, 'base64') + ); } catch (err) { log.warn('Failed to decrypt AWS_SESSION_TOKEN; skipping', { error: err instanceof Error ? err.message : String(err) }); } } + } else if (b.mode === 'bearer') { + out.unset = BEARER_MODE_UNSETS; + if (b.bearerTokenEnc) { + try { + out.set.AWS_BEARER_TOKEN_BEDROCK = this.safe.decryptString( + Buffer.from(b.bearerTokenEnc, 'base64') + ); + } catch (err) { + log.warn('Failed to decrypt AWS_BEARER_TOKEN_BEDROCK; skipping', { + error: err instanceof Error ? err.message : String(err) + }); + } + } } return out; diff --git a/src/preload/index.ts b/src/preload/index.ts index 9cf976d..0178eac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -46,7 +46,11 @@ import type { BuiltInProviderStatus, ModelEntry } from '../shared/pi-config-types'; -import type { RedactedBedrock, BedrockWritePatch } from '../shared/pi-env-injection-types'; +import type { + RedactedBedrock, + BedrockWritePatch, + BedrockSecretField +} from '../shared/pi-env-injection-types'; type Unsubscribe = () => void; @@ -362,7 +366,7 @@ const fleetApi = { (await typedInvoke<{ bedrock?: RedactedBedrock }>(IPC_CHANNELS.PI_ENV_READ_BEDROCK)).bedrock, writeBedrock: async (patch: BedrockWritePatch): Promise => typedInvoke(IPC_CHANNELS.PI_ENV_WRITE_BEDROCK, patch), - clearSecret: async (field: 'secretAccessKey' | 'sessionToken'): Promise => + clearSecret: async (field: BedrockSecretField): Promise => typedInvoke(IPC_CHANNELS.PI_ENV_CLEAR_SECRET, field), isEncryptionAvailable: async (): Promise => typedInvoke(IPC_CHANNELS.PI_ENV_IS_ENCRYPTION_AVAILABLE) diff --git a/src/renderer/src/components/settings/pi/PiBedrockPanel.tsx b/src/renderer/src/components/settings/pi/PiBedrockPanel.tsx index 3663570..baecfaa 100644 --- a/src/renderer/src/components/settings/pi/PiBedrockPanel.tsx +++ b/src/renderer/src/components/settings/pi/PiBedrockPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import type { BedrockWritePatch, + BedrockSecretField, PiBedrockCredentialMode, RedactedBedrock } from '../../../../../shared/pi-env-injection-types'; @@ -19,6 +20,7 @@ type Loaded = { accessKeyId: string; secretAccessKeyPresent: boolean; sessionTokenPresent: boolean; + bearerTokenPresent: boolean; encryptionAvailable: boolean; }; @@ -32,6 +34,7 @@ export function PiBedrockPanel({ const [state, setState] = useState({ kind: 'loading' }); const [secretDraft, setSecretDraft] = useState(''); const [sessionDraft, setSessionDraft] = useState(''); + const [bearerDraft, setBearerDraft] = useState(''); const [legacyBannerDismissed, setLegacyBannerDismissed] = useState(false); const load = async (): Promise => { @@ -43,7 +46,8 @@ export function PiBedrockPanel({ const r: RedactedBedrock = redacted ?? { mode: 'chain', secretAccessKeyPresent: false, - sessionTokenPresent: false + sessionTokenPresent: false, + bearerTokenPresent: false }; setState({ kind: 'loaded', @@ -53,6 +57,7 @@ export function PiBedrockPanel({ accessKeyId: r.accessKeyId ?? '', secretAccessKeyPresent: r.secretAccessKeyPresent, sessionTokenPresent: r.sessionTokenPresent, + bearerTokenPresent: r.bearerTokenPresent, encryptionAvailable }); } catch (err) { @@ -81,21 +86,23 @@ export function PiBedrockPanel({ }; const writeSecret = async ( - field: 'secretAccessKey' | 'sessionToken', + field: BedrockSecretField, value: string ): Promise => { await writePatch({ [field]: value }); if (field === 'secretAccessKey') setSecretDraft(''); - else setSessionDraft(''); + else if (field === 'sessionToken') setSessionDraft(''); + else setBearerDraft(''); }; - const clearSecret = async (field: 'secretAccessKey' | 'sessionToken'): Promise => { + const clearSecret = async (field: BedrockSecretField): Promise => { await window.fleet.piEnv.clearSecret(field); await load(); }; const showKeysFields = state.mode === 'keys'; const showProfileField = state.mode === 'profile'; + const showBearerFields = state.mode === 'bearer'; return (
@@ -169,6 +176,21 @@ export function PiBedrockPanel({ (OS keychain unavailable) )} +