From 7c06aee1ea98c372a28e4f8850026c76933ffc84 Mon Sep 17 00:00:00 2001 From: Kurt Stohrer Date: Tue, 12 May 2026 17:00:49 -0400 Subject: [PATCH 01/47] ANN-19: M4 provider config storage + budget cap + redaction + event log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage and guardrail layer for the embedded chat (ANN-8 / M4). Owner: AICoder. Designed so the DesignEngineer settings-sheet pass can bind to zod-validated shapes without churn. New modules: - src/embedded/provider-config.ts — zod schema and helpers for five providers (Anthropic, OpenAI/Codex, OpenAI-compatible, Copilot, Paperclip), per-conversation USD cap, redaction and event-log toggles, plus structured validation and safe-logging redactor. - src/embedded/redaction.ts — secret-pattern scrubber for prompts and tool results. Catches provider keys, GitHub PATs, AWS credentials, JWTs, PEM blocks, and env-style assignments. Idempotent; returns match offsets for telemetry without leaking redacted bytes. - src/embedded/budget-cap.ts — per-conversation BudgetCap with injectable pricer, sticky shouldStop() once breached, and BudgetCapExceeded exception path. pricerFromRateCard helper matches Anthropic's 10% cache-read / 125% cache-write convention. - src/embedded/event-log.ts — local-only ring buffer for provider turn, tool_call, tool_result, and error events. Bounded capacity, optional persistence sink, subscribe() for UI live updates, totalsByConversation for the composer's token meter. No fetch surface. Shell surface: - src/shell/composables/useProviderSettings.ts — reactive singleton backed by localStorage, exposes makeConversationBudget() and usageForConversation() for the composer cap chip + cost meter. - src/shell/components/ProviderSettingsPanel.vue — functional starter panel. DesignEngineer owns the visual polish; field names map 1:1 to the schema so renames here do not silently break storage. Tests: 49 new tests across redaction, budget-cap, event-log, provider-config, and useProviderSettings. All 116 vitest tests pass. Co-Authored-By: Paperclip --- src/embedded/__tests__/budget-cap.test.ts | 136 ++++++ src/embedded/__tests__/event-log.test.ts | 247 ++++++++++ .../__tests__/provider-config.test.ts | 265 +++++++++++ src/embedded/__tests__/redaction.test.ts | 167 +++++++ src/embedded/budget-cap.ts | 178 +++++++ src/embedded/event-log.ts | 274 +++++++++++ src/embedded/provider-config.ts | 394 ++++++++++++++++ src/embedded/redaction.ts | 219 +++++++++ .../components/ProviderSettingsPanel.vue | 444 ++++++++++++++++++ .../__tests__/useProviderSettings.test.ts | 105 +++++ src/shell/composables/useProviderSettings.ts | 211 +++++++++ 11 files changed, 2640 insertions(+) create mode 100644 src/embedded/__tests__/budget-cap.test.ts create mode 100644 src/embedded/__tests__/event-log.test.ts create mode 100644 src/embedded/__tests__/provider-config.test.ts create mode 100644 src/embedded/__tests__/redaction.test.ts create mode 100644 src/embedded/budget-cap.ts create mode 100644 src/embedded/event-log.ts create mode 100644 src/embedded/provider-config.ts create mode 100644 src/embedded/redaction.ts create mode 100644 src/shell/components/ProviderSettingsPanel.vue create mode 100644 src/shell/composables/__tests__/useProviderSettings.test.ts create mode 100644 src/shell/composables/useProviderSettings.ts diff --git a/src/embedded/__tests__/budget-cap.test.ts b/src/embedded/__tests__/budget-cap.test.ts new file mode 100644 index 0000000..b93d51b --- /dev/null +++ b/src/embedded/__tests__/budget-cap.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { + BudgetCap, + BudgetCapExceeded, + pricerFromRateCard, + type BudgetPricer, +} from '../budget-cap.js' + +// Simple linear pricer: $1 per 1k input tokens, $2 per 1k output. Easy +// math for assertions. +const flatPricer: BudgetPricer = ({ input, output }) => + (input / 1000) * 1 + (output / 1000) * 2 + +describe('BudgetCap', () => { + it('starts at zero', () => { + const b = new BudgetCap({ capUsd: 1, pricer: flatPricer }) + const s = b.snapshot() + expect(s.totalUsd).toBe(0) + expect(s.remainingUsd).toBe(1) + expect(s.exceeded).toBe(false) + expect(s.ratio).toBe(0) + }) + + it('accumulates usage and reports running total', () => { + const b = new BudgetCap({ capUsd: 10, pricer: flatPricer }) + b.accumulate({ input: 500, output: 250 }) + const s = b.snapshot() + // 0.5 + 0.5 = 1.0 + expect(s.totalUsd).toBeCloseTo(1.0, 6) + expect(s.remainingUsd).toBeCloseTo(9.0, 6) + expect(s.exceeded).toBe(false) + expect(s.ratio).toBeCloseTo(0.1, 6) + }) + + it('flips exceeded when total crosses cap', () => { + const b = new BudgetCap({ capUsd: 1, pricer: flatPricer }) + const a = b.accumulate({ input: 400, output: 200 }) + expect(a.exceeded).toBe(false) + const c = b.accumulate({ input: 400, output: 200 }) + // After two turns: input=800, output=400 → $0.8 + $0.8 = $1.6 + expect(c.totalUsd).toBeCloseTo(1.6, 6) + expect(c.exceeded).toBe(true) + expect(c.remainingUsd).toBe(0) + expect(c.ratio).toBe(1) + }) + + it('shouldStop stays true after a single breach (sticky)', () => { + const b = new BudgetCap({ capUsd: 0.1, pricer: flatPricer }) + b.accumulate({ input: 1000, output: 0 }) + expect(b.shouldStop()).toBe(true) + // Even if a follow-up turn arrives with zero usage, we stay stopped + // until reset(). The runner must call reset() between conversations. + b.accumulate({ input: 0, output: 0 }) + expect(b.shouldStop()).toBe(true) + }) + + it('enforce() throws BudgetCapExceeded with the snapshot details', () => { + const b = new BudgetCap({ capUsd: 0.5, pricer: flatPricer }) + b.accumulate({ input: 1000, output: 0 }) + try { + b.enforce() + throw new Error('enforce did not throw') + } catch (e) { + expect(e).toBeInstanceOf(BudgetCapExceeded) + const err = e as BudgetCapExceeded + expect(err.capUsd).toBe(0.5) + expect(err.totalUsd).toBeCloseTo(1.0, 6) + } + }) + + it('reset() clears state', () => { + const b = new BudgetCap({ capUsd: 0.1, pricer: flatPricer }) + b.accumulate({ input: 1000, output: 0 }) + expect(b.shouldStop()).toBe(true) + b.reset() + const s = b.snapshot() + expect(s.totalUsd).toBe(0) + expect(s.exceeded).toBe(false) + expect(s.totals).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }) + }) + + it('clamps negative and non-finite inputs to zero', () => { + const b = new BudgetCap({ capUsd: 1, pricer: flatPricer }) + b.accumulate({ input: -100, output: Number.NaN }) + b.accumulate({ input: 500, output: 250 }) + expect(b.snapshot().totalUsd).toBeCloseTo(1.0, 6) + }) + + it('rejects non-positive cap values', () => { + expect(() => new BudgetCap({ capUsd: 0, pricer: flatPricer })).toThrow() + expect(() => new BudgetCap({ capUsd: -1, pricer: flatPricer })).toThrow() + expect( + () => new BudgetCap({ capUsd: Number.NaN, pricer: flatPricer }), + ).toThrow() + expect( + () => new BudgetCap({ capUsd: Number.POSITIVE_INFINITY, pricer: flatPricer }), + ).toThrow() + }) + + it('tracks cache read/write buckets independently', () => { + const b = new BudgetCap({ capUsd: 10, pricer: flatPricer }) + b.accumulate({ input: 100, output: 50, cacheRead: 200, cacheWrite: 25 }) + const s = b.snapshot() + expect(s.totals.cacheRead).toBe(200) + expect(s.totals.cacheWrite).toBe(25) + }) + + it('ratio is exactly 1 at the cap boundary', () => { + const b = new BudgetCap({ capUsd: 1, pricer: flatPricer }) + // 500 input + 250 output → exactly $1.00. + b.accumulate({ input: 500, output: 250 }) + const s = b.snapshot() + expect(s.totalUsd).toBeCloseTo(1.0, 6) + expect(s.exceeded).toBe(true) + expect(s.ratio).toBe(1) + }) +}) + +describe('pricerFromRateCard', () => { + it('matches the Anthropic 10% cache-read / 125% cache-write convention', () => { + const pricer = pricerFromRateCard({ inputPerMTok: 3, outputPerMTok: 15 }) + const cost = pricer({ + input: 1_000_000, + output: 1_000_000, + cacheRead: 1_000_000, + cacheWrite: 1_000_000, + }) + // 3 + 15 + 0.3 + 3.75 = 22.05 + expect(cost).toBeCloseTo(22.05, 6) + }) + + it('returns 0 for zero usage', () => { + const pricer = pricerFromRateCard({ inputPerMTok: 3, outputPerMTok: 15 }) + expect(pricer({ input: 0, output: 0 })).toBe(0) + }) +}) diff --git a/src/embedded/__tests__/event-log.test.ts b/src/embedded/__tests__/event-log.test.ts new file mode 100644 index 0000000..5911ba6 --- /dev/null +++ b/src/embedded/__tests__/event-log.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi } from 'vitest' +import { EventLog, type PersistenceSink } from '../event-log.js' + +function memorySink(): PersistenceSink & { store: Map } { + const store = new Map() + return { + store, + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => { + store.set(k, v) + }, + removeItem: (k) => { + store.delete(k) + }, + } +} + +describe('EventLog', () => { + it('appends rows with monotonic ids and an injected clock', () => { + let t = 100 + const log = new EventLog({ now: () => t++ }) + const a = log.append({ + kind: 'turn', + conversationId: 'c1', + provider: 'anthropic', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 50, + latencyMs: 200, + }) + const b = log.append({ + kind: 'turn', + conversationId: 'c1', + provider: 'anthropic', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 50, + latencyMs: 200, + }) + expect(a.id).toBe(1) + expect(b.id).toBe(2) + expect(a.ts).toBe(100) + expect(b.ts).toBe(101) + expect(log.list()).toHaveLength(2) + }) + + it('appendToolCall captures top-level keys but not values', () => { + const log = new EventLog() + const row = log.appendToolCall({ + conversationId: 'c1', + provider: 'anthropic', + model: 'claude-sonnet-4-5', + toolName: 'edit_file', + toolUseId: 'tu_1', + input: { path: '/secrets/x', text: 'sk-ant-…' }, + }) + expect(row.inputKeys.sort()).toEqual(['path', 'text']) + // No values stored anywhere on the row. + expect(JSON.stringify(row)).not.toContain('/secrets/x') + expect(JSON.stringify(row)).not.toContain('sk-ant-') + }) + + it('aggregates per-conversation token totals', () => { + const log = new EventLog() + log.append({ + kind: 'turn', + conversationId: 'c1', + provider: 'anthropic', + model: 'm', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 10, + cacheCreationTokens: 5, + latencyMs: 200, + }) + log.append({ + kind: 'turn', + conversationId: 'c1', + provider: 'anthropic', + model: 'm', + inputTokens: 40, + outputTokens: 30, + latencyMs: 200, + }) + log.append({ + kind: 'turn', + conversationId: 'c2', + provider: 'anthropic', + model: 'm', + inputTokens: 999, + outputTokens: 999, + latencyMs: 200, + }) + const t = log.totalsByConversation('c1') + expect(t.inputTokens).toBe(140) + expect(t.outputTokens).toBe(80) + expect(t.cacheReadTokens).toBe(10) + expect(t.cacheCreationTokens).toBe(5) + expect(t.turns).toBe(2) + }) + + it('evicts oldest rows when capacity is exceeded', () => { + const log = new EventLog({ capacity: 3 }) + for (let i = 0; i < 5; i++) { + log.append({ + kind: 'turn', + conversationId: 'c', + provider: 'p', + model: 'm', + inputTokens: i, + outputTokens: 0, + latencyMs: 1, + }) + } + const rows = log.list() + expect(rows).toHaveLength(3) + // The two oldest rows (ids 1 and 2) were evicted; we kept 3, 4, 5. + expect(rows.map((r) => r.id)).toEqual([3, 4, 5]) + }) + + it('notifies subscribers on append and clear', () => { + const log = new EventLog() + const seen: number[] = [] + const unsubscribe = log.subscribe((rows) => seen.push(rows.length)) + log.append({ + kind: 'turn', + conversationId: 'c', + provider: 'p', + model: 'm', + inputTokens: 1, + outputTokens: 1, + latencyMs: 1, + }) + log.append({ + kind: 'tool_result', + conversationId: 'c', + toolUseId: 'tu_1', + toolName: 'edit', + isError: false, + latencyMs: 5, + }) + log.clear() + unsubscribe() + log.append({ + kind: 'turn', + conversationId: 'c', + provider: 'p', + model: 'm', + inputTokens: 1, + outputTokens: 1, + latencyMs: 1, + }) + expect(seen).toEqual([1, 2, 0]) + }) + + it('persists to an injected sink and rehydrates on construction', () => { + const sink = memorySink() + const a = new EventLog({ persistence: sink }) + a.append({ + kind: 'turn', + conversationId: 'c1', + provider: 'anthropic', + model: 'm', + inputTokens: 100, + outputTokens: 50, + latencyMs: 200, + }) + const b = new EventLog({ persistence: sink }) + expect(b.list()).toHaveLength(1) + // Continue numbering from where we left off so reloads don't reset ids. + const next = b.append({ + kind: 'turn', + conversationId: 'c1', + provider: 'anthropic', + model: 'm', + inputTokens: 100, + outputTokens: 50, + latencyMs: 200, + }) + expect(next.id).toBeGreaterThan(1) + }) + + it('survives corrupt persisted state by clearing it', () => { + const sink = memorySink() + sink.store.set('annotask:ai:eventLog', '{not valid json') + const log = new EventLog({ persistence: sink }) + expect(log.list()).toEqual([]) + expect(sink.store.has('annotask:ai:eventLog')).toBe(false) + }) + + it('does NOT touch the network — listener-free fetch surface', () => { + // Sanity check that the module does not reach for `fetch`. We spy on + // the global; any call would fail this test. + const spy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + throw new Error('EventLog touched the network') + }) + try { + const log = new EventLog() + log.append({ + kind: 'turn', + conversationId: 'c', + provider: 'p', + model: 'm', + inputTokens: 1, + outputTokens: 1, + latencyMs: 1, + }) + log.append({ + kind: 'error', + conversationId: 'c', + provider: 'p', + model: 'm', + reason: 'rate_limit', + }) + log.clear() + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } + }) + + it('filter returns matching rows', () => { + const log = new EventLog() + log.append({ + kind: 'turn', + conversationId: 'c', + provider: 'p', + model: 'm', + inputTokens: 1, + outputTokens: 1, + latencyMs: 1, + }) + log.append({ + kind: 'error', + conversationId: 'c', + provider: 'p', + model: 'm', + reason: 'rate_limit', + }) + expect(log.filter((r) => r.kind === 'error')).toHaveLength(1) + }) + + it('rejects capacity <= 0', () => { + expect(() => new EventLog({ capacity: 0 })).toThrow() + expect(() => new EventLog({ capacity: -10 })).toThrow() + }) +}) diff --git a/src/embedded/__tests__/provider-config.test.ts b/src/embedded/__tests__/provider-config.test.ts new file mode 100644 index 0000000..366e043 --- /dev/null +++ b/src/embedded/__tests__/provider-config.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect } from 'vitest' +import { + DEFAULT_PROVIDER_SETTINGS, + parseProviderSettings, + validateProviderConfig, + isActiveProviderReady, + redactForLogging, + ProviderSettingsSchema, + type ProviderSettings, +} from '../provider-config.js' + +function withActiveProvider( + patch: Partial, + active: ProviderSettings['activeProvider'], +): ProviderSettings { + return { + ...DEFAULT_PROVIDER_SETTINGS, + activeProvider: active, + providers: { + ...DEFAULT_PROVIDER_SETTINGS.providers, + ...patch, + }, + } +} + +describe('ProviderSettingsSchema defaults', () => { + it('produces a complete default settings blob', () => { + const d = DEFAULT_PROVIDER_SETTINGS + expect(d.activeProvider).toBe('anthropic') + expect(d.perConversationCapUsd).toBe(0.5) + expect(d.redactionEnabled).toBe(true) + expect(d.eventLogEnabled).toBe(true) + expect(d.providers.anthropic.apiKey).toBe('') + expect(d.providers.anthropic.model).toBe('claude-sonnet-4-5') + expect(d.providers.openai.model).toBe('gpt-5') + expect(d.providers.copilot.oauthToken).toBe('') + expect(d.providers.paperclip.companyId).toBe('') + }) + + it('rejects a non-positive perConversationCapUsd', () => { + const bad = ProviderSettingsSchema.safeParse({ + ...DEFAULT_PROVIDER_SETTINGS, + perConversationCapUsd: -1, + }) + expect(bad.success).toBe(false) + }) +}) + +describe('parseProviderSettings', () => { + it('parses a JSON string', () => { + const blob = JSON.stringify(DEFAULT_PROVIDER_SETTINGS) + const out = parseProviderSettings(blob) + expect(out.activeProvider).toBe('anthropic') + }) + + it('falls back to defaults on invalid JSON', () => { + const out = parseProviderSettings('{not valid') + expect(out).toEqual(DEFAULT_PROVIDER_SETTINGS) + }) + + it('falls back to defaults on unknown shape', () => { + const out = parseProviderSettings({ foo: 'bar' }) + expect(out.activeProvider).toBe('anthropic') + }) + + it('returns defaults when input is null/undefined', () => { + expect(parseProviderSettings(null)).toEqual(DEFAULT_PROVIDER_SETTINGS) + expect(parseProviderSettings(undefined)).toEqual(DEFAULT_PROVIDER_SETTINGS) + }) + + it('round-trips a populated config', () => { + const populated: ProviderSettings = { + ...DEFAULT_PROVIDER_SETTINGS, + activeProvider: 'openai', + providers: { + ...DEFAULT_PROVIDER_SETTINGS.providers, + openai: { + id: 'openai', + apiKey: 'sk-test', + organization: 'org-abc', + model: 'gpt-5', + }, + }, + } + const round = parseProviderSettings(JSON.stringify(populated)) + expect(round.activeProvider).toBe('openai') + expect(round.providers.openai.apiKey).toBe('sk-test') + expect(round.providers.openai.organization).toBe('org-abc') + }) +}) + +describe('validateProviderConfig', () => { + it('accepts an anthropic config with a non-empty key', () => { + const r = validateProviderConfig({ + id: 'anthropic', + apiKey: 'sk-ant-abc', + model: 'claude-sonnet-4-5', + }) + expect(r.ok).toBe(true) + }) + + it('accepts an openai-compatible endpoint at http://localhost', () => { + const r = validateProviderConfig({ + id: 'openai-compatible', + endpointUrl: 'http://localhost:4096/v1', + apiKey: '', + model: 'qwen2.5-coder', + label: 'opencode local', + }) + expect(r.ok).toBe(true) + }) + + it('rejects an openai-compatible endpoint with embedded credentials', () => { + const r = validateProviderConfig({ + id: 'openai-compatible', + endpointUrl: 'http://user:pass@localhost:4096/v1', + apiKey: '', + model: 'qwen2.5-coder', + label: '', + }) + expect(r.ok).toBe(false) + if (!r.ok) { + expect(r.errors.join('\n')).toMatch(/credentials/) + } + }) + + it('rejects an openai-compatible endpoint with a non-http(s) scheme', () => { + const r = validateProviderConfig({ + id: 'openai-compatible', + endpointUrl: 'ftp://example.com', + apiKey: '', + model: 'q', + label: '', + }) + expect(r.ok).toBe(false) + }) + + it('rejects an openai-compatible endpoint that is not a URL', () => { + const r = validateProviderConfig({ + id: 'openai-compatible', + endpointUrl: 'localhost', + apiKey: '', + model: 'q', + label: '', + }) + expect(r.ok).toBe(false) + }) + + it('returns human-readable error messages with field paths', () => { + const r = validateProviderConfig({ + id: 'openai-compatible', + endpointUrl: '', + apiKey: '', + model: '', + label: '', + }) + expect(r.ok).toBe(false) + if (!r.ok) { + const text = r.errors.join('\n') + expect(text).toContain('endpointUrl') + expect(text).toContain('model') + } + }) +}) + +describe('isActiveProviderReady', () => { + it('false on default (no credentials)', () => { + expect(isActiveProviderReady(DEFAULT_PROVIDER_SETTINGS)).toBe(false) + }) + + it('true when Anthropic has a key', () => { + const s = withActiveProvider( + { + anthropic: { id: 'anthropic', apiKey: 'sk-ant-abc', model: 'claude-sonnet-4-5' }, + }, + 'anthropic', + ) + expect(isActiveProviderReady(s)).toBe(true) + }) + + it('true when Copilot has an OAuth token', () => { + const s = withActiveProvider( + { + copilot: { id: 'copilot', oauthToken: 'gho_x', model: 'gpt-5' }, + }, + 'copilot', + ) + expect(isActiveProviderReady(s)).toBe(true) + }) + + it('true when Paperclip has a company key', () => { + const s = withActiveProvider( + { + paperclip: { + id: 'paperclip', + companyId: 'c1', + apiKey: 'pcli_x', + apiBaseUrl: '', + model: 'paperclip-default', + }, + }, + 'paperclip', + ) + expect(isActiveProviderReady(s)).toBe(true) + }) + + it('false for openai-compatible without endpoint url', () => { + const s = withActiveProvider( + { + 'openai-compatible': { + id: 'openai-compatible', + endpointUrl: '', + apiKey: '', + model: 'q', + label: '', + }, + }, + 'openai-compatible', + ) + expect(isActiveProviderReady(s)).toBe(false) + }) + + it('true for openai-compatible with endpoint url and model', () => { + const s = withActiveProvider( + { + 'openai-compatible': { + id: 'openai-compatible', + endpointUrl: 'http://localhost:4096/v1', + apiKey: '', + model: 'q', + label: '', + }, + }, + 'openai-compatible', + ) + expect(isActiveProviderReady(s)).toBe(true) + }) +}) + +describe('redactForLogging', () => { + it('replaces every secret with a / marker', () => { + const populated: ProviderSettings = { + ...DEFAULT_PROVIDER_SETTINGS, + providers: { + ...DEFAULT_PROVIDER_SETTINGS.providers, + anthropic: { id: 'anthropic', apiKey: 'sk-ant-secret', model: 'm' }, + copilot: { id: 'copilot', oauthToken: 'gho_secret', model: 'm' }, + paperclip: { + id: 'paperclip', + companyId: 'c1', + apiKey: 'pcli_secret', + apiBaseUrl: '', + model: 'm', + }, + }, + } + const out = JSON.stringify(redactForLogging(populated)) + expect(out).not.toContain('sk-ant-secret') + expect(out).not.toContain('gho_secret') + expect(out).not.toContain('pcli_secret') + expect(out).toContain('') + // Non-secret company id stays. + expect(out).toContain('c1') + }) +}) diff --git a/src/embedded/__tests__/redaction.test.ts b/src/embedded/__tests__/redaction.test.ts new file mode 100644 index 0000000..4ec52c2 --- /dev/null +++ b/src/embedded/__tests__/redaction.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest' +import { + redact, + redactString, + redactValue, + DEFAULT_REDACTION_PATTERNS, + type RedactionPattern, +} from '../redaction.js' + +describe('redact', () => { + it('redacts Anthropic api keys', () => { + const input = 'use sk-ant-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890 to call' + const { output, matches } = redact(input) + expect(output).toBe('use [REDACTED:anthropic_api_key] to call') + expect(matches).toHaveLength(1) + expect(matches[0].label).toBe('anthropic_api_key') + }) + + it('redacts OpenAI api keys including project-scoped form', () => { + const a = redactString('OPENAI_KEY=sk-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890') + expect(a).toContain('[REDACTED:openai_api_key]') + + const b = redactString('key sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890 ok') + expect(b).toContain('[REDACTED:openai_api_key]') + }) + + it('redacts GitHub PATs and classic tokens', () => { + // Real github_pat_ format: 22 base62 + _ + 59 base62. The test fixture + // here matches that exactly so the parser sees a valid PAT. + const tail = 'AbCdEfGhIjKlMnOpQrStUv' // 22 + const secret = 'WxYz0123456789AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGhI' // 59 + const pat = redactString(`token=github_pat_${tail}_${secret}`) + expect(pat).toContain('[REDACTED:github_pat]') + + const classic = redactString('ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890ab here') + expect(classic).toContain('[REDACTED:github_token]') + }) + + it('redacts AWS access key ids and labelled secrets', () => { + const k = redactString('AKIAIOSFODNN7EXAMPLE was leaked') + expect(k).toContain('[REDACTED:aws_access_key_id]') + + const s = redactString('aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY rest') + expect(s).toContain('[REDACTED:aws_secret_access_key]') + }) + + it('redacts JWT-shaped tokens', () => { + const jwt = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.dBjftJeZ4CVPMZ4Y8c0Knw5XfM5xqZx8rJk1lT0wZ7s' + const r = redactString(`Authorization: Bearer ${jwt}`) + // Either bearer_token or jwt label is acceptable as long as the secret + // is gone. + expect(r).not.toContain(jwt) + expect(r).toMatch(/\[REDACTED:(bearer_token|jwt)\]/) + }) + + it('redacts PEM private key blocks across lines and beats inner matches', () => { + const inner = 'sk-ant-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890' + const pem = `-----BEGIN RSA PRIVATE KEY-----\nMIIBOQIBAAJAa${inner}\nABCDEF\n-----END RSA PRIVATE KEY-----` + const r = redactString(pem) + expect(r).toBe('[REDACTED:private_key_pem]') + expect(r).not.toContain(inner) + }) + + it('redacts env-style assignments only by value', () => { + const input = 'GH_TOKEN=ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890ab\nAPI_KEY=mySuperSecret123' + const r = redactString(input) + // The variable name MUST stay so a debugger can see context. + expect(r).toContain('GH_TOKEN=') + expect(r).toContain('API_KEY=') + // The value is gone. + expect(r).not.toContain('mySuperSecret123') + expect(r).not.toContain('ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890ab') + }) + + it('leaves benign text untouched', () => { + const input = 'The quick brown fox jumps over the lazy dog.' + expect(redactString(input)).toBe(input) + }) + + it('is idempotent', () => { + const input = 'key=sk-ant-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890' + const once = redactString(input) + const twice = redactString(once) + expect(twice).toBe(once) + }) + + it('returns match offsets in the original string', () => { + const input = 'pre sk-ant-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890 post' + const { matches } = redact(input) + expect(matches).toHaveLength(1) + expect(input.slice(matches[0].start, matches[0].end)).toBe( + 'sk-ant-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890', + ) + }) + + it('handles empty input', () => { + expect(redactString('')).toBe('') + expect(redact('').matches).toEqual([]) + }) + + it('respects custom pattern lists', () => { + const patterns: RedactionPattern[] = [ + { label: 'customer_id', pattern: /\bCUST-\d{6}\b/g }, + ] + const r = redactString('order for CUST-123456 ok', patterns) + expect(r).toBe('order for [REDACTED:customer_id] ok') + }) + + it('exports a default pattern set with the documented labels', () => { + const labels = new Set(DEFAULT_REDACTION_PATTERNS.map((p) => p.label)) + for (const required of [ + 'anthropic_api_key', + 'openai_api_key', + 'github_token', + 'aws_access_key_id', + 'private_key_pem', + 'jwt', + 'bearer_token', + ]) { + expect(labels.has(required)).toBe(true) + } + }) + + it('does not match short alphanum runs in prose', () => { + // The previous "Bearer" rule was anchored on the literal word; bare + // tokens without the prefix should pass through. + const input = 'I love coding in TypeScript on weekdays at home in Brooklyn' + expect(redactString(input)).toBe(input) + }) +}) + +describe('redactValue', () => { + it('walks plain objects and arrays', () => { + const input = { + provider: 'anthropic', + key: 'sk-ant-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890', + nested: { + tokens: ['ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890ab', 'safe-value'], + }, + count: 7, + ok: true, + } + const out = redactValue(input) + expect(out.provider).toBe('anthropic') + expect(out.key).toBe('[REDACTED:anthropic_api_key]') + expect(out.nested.tokens[0]).toBe('[REDACTED:github_token]') + expect(out.nested.tokens[1]).toBe('safe-value') + expect(out.count).toBe(7) + expect(out.ok).toBe(true) + }) + + it('returns non-plain-object values unchanged', () => { + const date = new Date() + expect(redactValue(date)).toBe(date) + const map = new Map([['k', 'v']]) + expect(redactValue(map)).toBe(map) + expect(redactValue(null)).toBe(null) + expect(redactValue(undefined)).toBe(undefined) + }) + + it('does not mutate the input', () => { + const input = { key: 'sk-ant-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890' } + redactValue(input) + expect(input.key).toBe('sk-ant-AbCdEfGhIjKlMnOpQrStUvWxYz1234567890') + }) +}) diff --git a/src/embedded/budget-cap.ts b/src/embedded/budget-cap.ts new file mode 100644 index 0000000..613daee --- /dev/null +++ b/src/embedded/budget-cap.ts @@ -0,0 +1,178 @@ +/** + * Per-conversation budget cap. + * + * The embedded chat loop is BYOK and runs locally — the only thing that + * stops a runaway tool-using loop is the cap configured here. We enforce it + * defensively after every provider turn (token-based, before money is + * counted) and surface a structured stop reason the runner can render in + * the UI without ambiguity. + * + * Model: + * - The cap is denominated in USD. The runner converts token counts to USD + * via a `pricer` callback (kept injectable so different providers can + * plug in their own rate cards without this module knowing them). + * - We accumulate input + output (+ optional cache buckets) into a running + * total. Every accumulate() call returns whether the cap is now breached, + * so the runner can short-circuit before the next provider turn. + * - `state` is never negative; clamping is done at the input edge. + * + * Hard-stop semantics: + * - Once breached, `shouldStop()` stays true until `reset()`. The runner + * MUST abort the in-flight turn and emit a terminal event with reason + * `cap_exceeded` so the UI can show the cap card. + */ + +export interface BudgetUsage { + input: number + output: number + cacheRead?: number + cacheWrite?: number +} + +export interface BudgetPricer { + /** Convert a usage tally into USD. Must be pure. */ + (usage: BudgetUsage): number +} + +export interface BudgetCapOptions { + /** Hard cap in USD. Must be > 0. */ + capUsd: number + /** Pricer that maps usage → USD. */ + pricer: BudgetPricer +} + +export interface BudgetSnapshot { + totalUsd: number + capUsd: number + /** True iff `totalUsd >= capUsd`. */ + exceeded: boolean + /** Remaining USD before the cap. Never negative. */ + remainingUsd: number + /** % of cap consumed (0..1). Saturates at 1. */ + ratio: number + totals: Required +} + +export class BudgetCapExceeded extends Error { + readonly capUsd: number + readonly totalUsd: number + constructor(snapshot: BudgetSnapshot) { + super( + `Budget cap exceeded: $${snapshot.totalUsd.toFixed(4)} spent of $${snapshot.capUsd.toFixed(2)} cap.`, + ) + this.name = 'BudgetCapExceeded' + this.capUsd = snapshot.capUsd + this.totalUsd = snapshot.totalUsd + } +} + +/** + * Conversation-scoped budget tracker. One instance per task/conversation. + * + * Usage: + * ```ts + * const budget = new BudgetCap({ capUsd: 0.5, pricer }) + * for await (const turn of loop) { + * const snap = budget.accumulate(turn.usage) + * if (snap.exceeded) { + * yield { kind: 'done', reason: 'cap_exceeded' } + * return + * } + * } + * ``` + */ +export class BudgetCap { + private readonly capUsd: number + private readonly pricer: BudgetPricer + private totals: Required = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + } + + constructor(options: BudgetCapOptions) { + if (!(options.capUsd > 0) || !Number.isFinite(options.capUsd)) { + throw new Error(`BudgetCap: capUsd must be a positive finite number, got ${options.capUsd}`) + } + this.capUsd = options.capUsd + this.pricer = options.pricer + } + + /** + * Add usage from a single provider turn to the running total and return + * the new snapshot. The caller decides what to do when `exceeded` flips + * true; this class does not throw on accumulate. + */ + accumulate(usage: BudgetUsage): BudgetSnapshot { + this.totals = { + input: this.totals.input + clamp(usage.input), + output: this.totals.output + clamp(usage.output), + cacheRead: this.totals.cacheRead + clamp(usage.cacheRead ?? 0), + cacheWrite: this.totals.cacheWrite + clamp(usage.cacheWrite ?? 0), + } + return this.snapshot() + } + + /** + * Throw `BudgetCapExceeded` if the cap is breached. Convenience for + * runners that prefer an exception over a sentinel return value. + */ + enforce(): void { + const snap = this.snapshot() + if (snap.exceeded) throw new BudgetCapExceeded(snap) + } + + shouldStop(): boolean { + return this.snapshot().exceeded + } + + snapshot(): BudgetSnapshot { + const totalUsd = this.pricer(this.totals) + const cap = this.capUsd + const exceeded = totalUsd >= cap + const remainingUsd = Math.max(0, cap - totalUsd) + const ratio = cap === 0 ? 1 : Math.min(1, totalUsd / cap) + return { + totalUsd, + capUsd: cap, + exceeded, + remainingUsd, + ratio, + totals: { ...this.totals }, + } + } + + reset(): void { + this.totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + } +} + +function clamp(n: number | undefined): number { + if (n == null || !Number.isFinite(n) || n < 0) return 0 + return n +} + +/** + * Pricer factory matching the embedded-agent pricing module. Kept here so + * tests don't need to import the UI-shell pricing module — callers in the + * shell can pass `priceUsage`'s output directly through their own thin + * wrapper if they prefer. + */ +export function pricerFromRateCard(rates: { + inputPerMTok: number + outputPerMTok: number + cacheReadRate?: number + cacheWriteRate?: number +}): BudgetPricer { + const cacheRead = rates.cacheReadRate ?? 0.1 + const cacheWrite = rates.cacheWriteRate ?? 1.25 + return ({ input, output, cacheRead: cr = 0, cacheWrite: cw = 0 }) => { + return ( + (input / 1_000_000) * rates.inputPerMTok + + (output / 1_000_000) * rates.outputPerMTok + + (cr / 1_000_000) * rates.inputPerMTok * cacheRead + + (cw / 1_000_000) * rates.inputPerMTok * cacheWrite + ) + } +} diff --git a/src/embedded/event-log.ts b/src/embedded/event-log.ts new file mode 100644 index 0000000..9c10000 --- /dev/null +++ b/src/embedded/event-log.ts @@ -0,0 +1,274 @@ +/** + * Local-only provider event log. + * + * Captures one row per provider turn (and per tool call) for inspection in + * the AI settings panel and to support debugging. Strict requirements: + * + * 1. **No network egress.** This module is forbidden from making any + * `fetch`/XHR/network call. It writes to an in-memory ring buffer that + * can optionally persist into `localStorage` (caller-controlled). + * 2. **Bounded memory.** Old rows are evicted when the buffer cap is hit, + * so a long session can't grow without bound. + * 3. **No secret leakage.** Rows store counts and labels, not message + * bodies. Tool-call arguments are recorded as a JSON sketch (top-level + * keys only) — never the full payload. + * + * The log is meant for the user — never for telemetry to a hosted server. + */ + +export type ProviderEventKind = 'turn' | 'tool_call' | 'tool_result' | 'error' + +export interface BaseEventRow { + /** Monotonic id assigned on append. */ + id: number + /** Wall-clock timestamp (ms since epoch) of the recorded event. */ + ts: number + /** Conversation/task this event belongs to. */ + conversationId: string + kind: ProviderEventKind +} + +export interface TurnEventRow extends BaseEventRow { + kind: 'turn' + provider: string + model: string + /** Tokens billed at the standard input rate. */ + inputTokens: number + /** Tokens billed at the output rate. */ + outputTokens: number + /** Cache-read tokens, when the provider exposes them. */ + cacheReadTokens?: number + /** Cache-write tokens, when the provider exposes them. */ + cacheCreationTokens?: number + /** Wall-clock latency, in milliseconds, from request to terminal event. */ + latencyMs: number + /** Optional reason emitted by the provider (`'end_turn'`, `'tool_use'`, …). */ + stopReason?: string +} + +export interface ToolCallEventRow extends BaseEventRow { + kind: 'tool_call' + provider: string + model: string + /** Tool name the model invoked. */ + toolName: string + /** Provider tool-call id, so we can pair the eventual `tool_result`. */ + toolUseId: string + /** Top-level keys of the tool input. Never the values. */ + inputKeys: string[] +} + +export interface ToolResultEventRow extends BaseEventRow { + kind: 'tool_result' + toolUseId: string + toolName: string + /** True iff the tool reported an error rather than a normal result. */ + isError: boolean + /** Wall-clock latency from `tool_call` append to `tool_result` append. */ + latencyMs: number +} + +export interface ErrorEventRow extends BaseEventRow { + kind: 'error' + provider: string + model: string + /** Short label (`network`, `rate_limit`, `cap_exceeded`, …). */ + reason: string + /** Optional human-readable detail. Already-redacted. */ + detail?: string +} + +export type EventRow = TurnEventRow | ToolCallEventRow | ToolResultEventRow | ErrorEventRow + +export type EventInput = + | (Omit & { ts?: number }) + | (Omit & { ts?: number }) + | (Omit & { ts?: number }) + | (Omit & { ts?: number }) + +export interface EventLogOptions { + /** Max rows held in memory. Older rows are dropped. Defaults to 500. */ + capacity?: number + /** + * Optional sink for persisted reads/writes. Pass `localStorage` in the + * shell; tests can pass an in-memory shim or omit entirely. + */ + persistence?: PersistenceSink + /** Persistence storage key. Defaults to `annotask:ai:eventLog`. */ + persistenceKey?: string + /** Clock injection seam for deterministic tests. Defaults to `Date.now`. */ + now?: () => number +} + +export interface PersistenceSink { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void +} + +const DEFAULT_CAPACITY = 500 +const DEFAULT_PERSISTENCE_KEY = 'annotask:ai:eventLog' + +export class EventLog { + private rows: EventRow[] = [] + private nextId = 1 + private listeners = new Set<(rows: ReadonlyArray) => void>() + private readonly capacity: number + private readonly persistence?: PersistenceSink + private readonly persistenceKey: string + private readonly now: () => number + + constructor(options: EventLogOptions = {}) { + this.capacity = options.capacity ?? DEFAULT_CAPACITY + if (!(this.capacity > 0)) { + throw new Error(`EventLog: capacity must be > 0, got ${this.capacity}`) + } + this.persistence = options.persistence + this.persistenceKey = options.persistenceKey ?? DEFAULT_PERSISTENCE_KEY + this.now = options.now ?? (() => Date.now()) + this.hydrate() + } + + append(event: EventInput): EventRow { + const row = { + ...(event as object), + id: this.nextId++, + ts: event.ts ?? this.now(), + } as EventRow + this.rows.push(row) + if (this.rows.length > this.capacity) { + // Drop the oldest rows. We drop from the front rather than mutating + // in-place so the listeners always see a stable list. + this.rows = this.rows.slice(this.rows.length - this.capacity) + } + this.persist() + this.notify() + return row + } + + /** + * Idiomatic helper for the runner: builds a `tool_call` row with the + * top-level argument keys captured for inspection. + */ + appendToolCall(args: { + conversationId: string + provider: string + model: string + toolName: string + toolUseId: string + input: unknown + }): ToolCallEventRow { + const inputKeys = + args.input && typeof args.input === 'object' && !Array.isArray(args.input) + ? Object.keys(args.input as Record) + : [] + return this.append({ + kind: 'tool_call', + conversationId: args.conversationId, + provider: args.provider, + model: args.model, + toolName: args.toolName, + toolUseId: args.toolUseId, + inputKeys, + }) as ToolCallEventRow + } + + list(): ReadonlyArray { + return this.rows + } + + /** + * Filter helper used by the AI panel's "Activity" view. + */ + filter(predicate: (row: EventRow) => boolean): ReadonlyArray { + return this.rows.filter(predicate) + } + + clear(): void { + if (this.rows.length === 0) return + this.rows = [] + this.persist() + this.notify() + } + + /** + * Subscribe to log updates. Returns an unsubscribe function. The + * listener is called with the new row list after every append/clear. + */ + subscribe(listener: (rows: ReadonlyArray) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** + * Aggregate totals across all `turn` rows. Useful for the per-task token + * meter in the composer (`ProviderEvent.usage`-style readout). + */ + totalsByConversation(conversationId: string): { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + turns: number + } { + let inputTokens = 0 + let outputTokens = 0 + let cacheReadTokens = 0 + let cacheCreationTokens = 0 + let turns = 0 + for (const row of this.rows) { + if (row.kind !== 'turn' || row.conversationId !== conversationId) continue + inputTokens += row.inputTokens + outputTokens += row.outputTokens + cacheReadTokens += row.cacheReadTokens ?? 0 + cacheCreationTokens += row.cacheCreationTokens ?? 0 + turns += 1 + } + return { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, turns } + } + + private persist(): void { + if (!this.persistence) return + try { + this.persistence.setItem(this.persistenceKey, JSON.stringify({ + nextId: this.nextId, + rows: this.rows, + })) + } catch { + // Persistence is best-effort — quota or serialization errors must not + // break the runner. + } + } + + private hydrate(): void { + if (!this.persistence) return + try { + const raw = this.persistence.getItem(this.persistenceKey) + if (!raw) return + const parsed = JSON.parse(raw) as { nextId?: number; rows?: EventRow[] } + if (Array.isArray(parsed.rows)) { + this.rows = parsed.rows.slice(-this.capacity) + } + if (typeof parsed.nextId === 'number' && parsed.nextId > this.nextId) { + this.nextId = parsed.nextId + } + } catch { + // Corrupt persisted state — discard and start clean. + this.persistence.removeItem(this.persistenceKey) + } + } + + private notify(): void { + if (this.listeners.size === 0) return + const snapshot = this.rows + for (const l of this.listeners) { + try { + l(snapshot) + } catch { + // Listener failure must not break appends. + } + } + } +} diff --git a/src/embedded/provider-config.ts b/src/embedded/provider-config.ts new file mode 100644 index 0000000..2fa29fd --- /dev/null +++ b/src/embedded/provider-config.ts @@ -0,0 +1,394 @@ +/** + * Provider configuration for the embedded chat surface. + * + * Storage posture: + * - Lives entirely in the user's browser (localStorage or whatever sink the + * shell injects). Never transmitted to any Annotask service. + * - Secrets (API keys, OAuth tokens) sit alongside non-secrets so the user + * has one place to configure the embedded chat. + * + * Why this module exists separately from `useAIConfig`: + * - `useAIConfig` is Anthropic-only and ANN-3 era. M4 needs five providers + * plus auth modes (BYOK key, Copilot OAuth, Paperclip company link, + * custom OpenAI-compatible endpoint URL). + * - The settings sheet (DesignEngineer) needs a single zod-validated source + * of truth it can bind to. UI churn must not invalidate stored configs. + * + * The schema is intentionally narrow. New auth modes get a new discriminated + * union branch rather than free-form fields. + */ + +import { z } from 'zod' + +export const PROVIDER_IDS = [ + 'anthropic', + 'openai', + 'openai-compatible', + 'copilot', + 'paperclip', +] as const + +export type ProviderId = (typeof PROVIDER_IDS)[number] + +export const ProviderIdSchema = z.enum(PROVIDER_IDS) + +const TrimmedString = z.string().trim() +const NonEmptyTrimmed = TrimmedString.min(1, 'value cannot be empty') + +/** + * URL validator that accepts http(s) URLs only and rejects auth credentials + * in the URL itself. We deliberately allow custom hosts (localhost, LAN + * IPs, hostnames) because opencode and LM Studio commonly run on + * `http://localhost:4096` and similar. + */ +const EndpointUrl = NonEmptyTrimmed.superRefine((value, ctx) => { + let url: URL + try { + url = new URL(value) + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Must be a valid URL.', + }) + return + } + if (!/^https?:$/.test(url.protocol)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Only http(s) URLs are supported.', + }) + } + if (url.username || url.password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Embed credentials via the API key field, not the URL.', + }) + } +}) + +/** + * Anthropic provider — BYOK only. Keys start with `sk-ant-`; we accept any + * non-empty trimmed string and rely on the live `validate()` call to verify + * against the real API. + */ +export const AnthropicProviderConfigSchema = z.object({ + id: z.literal('anthropic'), + apiKey: TrimmedString.default(''), + model: TrimmedString.default('claude-sonnet-4-5'), +}) +export type AnthropicProviderConfig = z.infer + +/** + * OpenAI / Codex provider — BYOK with optional org id. + */ +export const OpenAIProviderConfigSchema = z.object({ + id: z.literal('openai'), + apiKey: TrimmedString.default(''), + /** Optional organization id (`org-…`). */ + organization: TrimmedString.default(''), + model: TrimmedString.default('gpt-5'), +}) +export type OpenAIProviderConfig = z.infer + +/** + * OpenAI-compatible endpoint (opencode, Ollama, LM Studio, vLLM, …). + * `endpointUrl` is mandatory for this branch. + */ +export const OpenAICompatibleProviderConfigSchema = z.object({ + id: z.literal('openai-compatible'), + endpointUrl: EndpointUrl, + apiKey: TrimmedString.default(''), + model: NonEmptyTrimmed, + /** Friendly label for the UI, e.g. "opencode local". */ + label: TrimmedString.default(''), +}) +export type OpenAICompatibleProviderConfig = z.infer< + typeof OpenAICompatibleProviderConfigSchema +> + +/** + * GitHub Copilot via the device-code OAuth flow (port of opencode's auth). + * The token field stores the short-lived OAuth access token; refresh is + * handled by the provider itself. We store nothing here that the user + * couldn't also retrieve from `~/.config/gh-copilot`. + */ +export const CopilotProviderConfigSchema = z.object({ + id: z.literal('copilot'), + /** OAuth access token returned by GitHub. Empty until the user signs in. */ + oauthToken: TrimmedString.default(''), + /** Wall-clock expiry of the access token (ms since epoch), if known. */ + expiresAt: z.number().int().nonnegative().optional(), + model: TrimmedString.default('gpt-5'), +}) +export type CopilotProviderConfig = z.infer + +/** + * Paperclip-managed inference. The user pastes a Paperclip company API key + * (or runs `pnpm paperclipai connect`). The shell never stores Paperclip + * credentials past this field. + */ +export const PaperclipProviderConfigSchema = z.object({ + id: z.literal('paperclip'), + companyId: TrimmedString.default(''), + apiKey: TrimmedString.default(''), + /** Override the Paperclip API base URL (e.g. self-hosted instance). */ + apiBaseUrl: TrimmedString.default(''), + model: TrimmedString.default('paperclip-default'), +}) +export type PaperclipProviderConfig = z.infer + +export const ProviderConfigSchema = z.discriminatedUnion('id', [ + AnthropicProviderConfigSchema, + OpenAIProviderConfigSchema, + OpenAICompatibleProviderConfigSchema, + CopilotProviderConfigSchema, + PaperclipProviderConfigSchema, +]) +export type ProviderConfig = z.infer + +/** + * Shape of the persisted settings blob. The active provider id selects + * which entry in `providers` is currently in use; the other branches stay + * populated so the user can flip between providers without re-entering + * credentials. + */ +export const ProviderSettingsSchema = z.object({ + /** Provider currently selected for new conversations. */ + activeProvider: ProviderIdSchema.default('anthropic'), + /** Per-conversation USD cap (M4 acceptance). */ + perConversationCapUsd: z.number().positive().finite().default(0.5), + /** Toggle to disable redaction in *the user's own* config (testing only). */ + redactionEnabled: z.boolean().default(true), + /** Local event log toggle. Defaults on. Logged data never leaves the box. */ + eventLogEnabled: z.boolean().default(true), + /** Stored config per provider. Branches the user hasn't touched stay at defaults. */ + providers: z + .object({ + anthropic: AnthropicProviderConfigSchema.default({ + id: 'anthropic', + apiKey: '', + model: 'claude-sonnet-4-5', + }), + openai: OpenAIProviderConfigSchema.default({ + id: 'openai', + apiKey: '', + organization: '', + model: 'gpt-5', + }), + 'openai-compatible': z + .object({ + id: z.literal('openai-compatible'), + endpointUrl: TrimmedString.default(''), + apiKey: TrimmedString.default(''), + model: TrimmedString.default(''), + label: TrimmedString.default(''), + }) + .default({ + id: 'openai-compatible', + endpointUrl: '', + apiKey: '', + model: '', + label: '', + }), + copilot: CopilotProviderConfigSchema.default({ + id: 'copilot', + oauthToken: '', + model: 'gpt-5', + }), + paperclip: PaperclipProviderConfigSchema.default({ + id: 'paperclip', + companyId: '', + apiKey: '', + apiBaseUrl: '', + model: 'paperclip-default', + }), + }) + .default(() => ({ + anthropic: { id: 'anthropic' as const, apiKey: '', model: 'claude-sonnet-4-5' }, + openai: { id: 'openai' as const, apiKey: '', organization: '', model: 'gpt-5' }, + 'openai-compatible': { + id: 'openai-compatible' as const, + endpointUrl: '', + apiKey: '', + model: '', + label: '', + }, + copilot: { id: 'copilot' as const, oauthToken: '', model: 'gpt-5' }, + paperclip: { + id: 'paperclip' as const, + companyId: '', + apiKey: '', + apiBaseUrl: '', + model: 'paperclip-default', + }, + })), +}) +export type ProviderSettings = z.infer + +export const DEFAULT_PROVIDER_SETTINGS: ProviderSettings = { + activeProvider: 'anthropic', + perConversationCapUsd: 0.5, + redactionEnabled: true, + eventLogEnabled: true, + providers: { + anthropic: { id: 'anthropic', apiKey: '', model: 'claude-sonnet-4-5' }, + openai: { id: 'openai', apiKey: '', organization: '', model: 'gpt-5' }, + 'openai-compatible': { + id: 'openai-compatible', + endpointUrl: '', + apiKey: '', + model: '', + label: '', + }, + copilot: { id: 'copilot', oauthToken: '', model: 'gpt-5' }, + paperclip: { + id: 'paperclip', + companyId: '', + apiKey: '', + apiBaseUrl: '', + model: 'paperclip-default', + }, + }, +} + +/** + * Validate a single provider config branch and return either a parsed + * config or a list of human-readable issues. The `openai-compatible` + * branch's endpoint URL is validated strictly here even though it accepts + * empty defaults in storage — callers who *use* the provider must pass a + * filled-in endpoint URL. + */ +export function validateProviderConfig( + config: ProviderConfig, +): { ok: true; config: ProviderConfig } | { ok: false; errors: string[] } { + let schema: z.ZodTypeAny + switch (config.id) { + case 'anthropic': + schema = AnthropicProviderConfigSchema + break + case 'openai': + schema = OpenAIProviderConfigSchema + break + case 'openai-compatible': + schema = OpenAICompatibleProviderConfigSchema + break + case 'copilot': + schema = CopilotProviderConfigSchema + break + case 'paperclip': + schema = PaperclipProviderConfigSchema + break + } + const parsed = schema.safeParse(config) + if (parsed.success) return { ok: true, config: parsed.data as ProviderConfig } + return { + ok: false, + errors: parsed.error.issues.map((i) => { + const path = i.path.length > 0 ? `${i.path.join('.')}: ` : '' + return `${path}${i.message}` + }), + } +} + +/** + * Parse a stored settings blob (e.g. from localStorage). Falls back to + * `DEFAULT_PROVIDER_SETTINGS` on any error so the shell never gets stuck + * with a corrupt config. + */ +export function parseProviderSettings(raw: unknown): ProviderSettings { + if (raw == null) return DEFAULT_PROVIDER_SETTINGS + let input: unknown = raw + if (typeof raw === 'string') { + try { + input = JSON.parse(raw) + } catch { + return DEFAULT_PROVIDER_SETTINGS + } + } + const parsed = ProviderSettingsSchema.safeParse(input) + if (!parsed.success) return DEFAULT_PROVIDER_SETTINGS + // Merge the parsed result over the hand-built defaults so any provider + // branch the user has never touched still has a usable default. zod 4's + // nested .default() only fires when the parent key is missing, not when + // it is present but partially-populated. + return { + activeProvider: parsed.data.activeProvider, + perConversationCapUsd: parsed.data.perConversationCapUsd, + redactionEnabled: parsed.data.redactionEnabled, + eventLogEnabled: parsed.data.eventLogEnabled, + providers: { + ...DEFAULT_PROVIDER_SETTINGS.providers, + ...parsed.data.providers, + }, + } +} + +/** + * Return true when the given settings blob has enough credentials for the + * active provider to actually run. Useful for the "Run" button gating in + * the composer. + */ +export function isActiveProviderReady(settings: ProviderSettings): boolean { + const active = settings.providers[settings.activeProvider] + switch (active.id) { + case 'anthropic': + return active.apiKey.trim().length > 0 + case 'openai': + return active.apiKey.trim().length > 0 + case 'openai-compatible': { + const result = OpenAICompatibleProviderConfigSchema.safeParse(active) + return result.success + } + case 'copilot': + return active.oauthToken.trim().length > 0 + case 'paperclip': + return active.apiKey.trim().length > 0 + } +} + +/** + * Redact secret fields for safe logging. Returns a shallow copy with + * sensitive fields replaced by `''` / `''` markers. + */ +export function redactForLogging(settings: ProviderSettings): unknown { + const mask = (v: string) => (v.length > 0 ? '' : '') + return { + activeProvider: settings.activeProvider, + perConversationCapUsd: settings.perConversationCapUsd, + redactionEnabled: settings.redactionEnabled, + eventLogEnabled: settings.eventLogEnabled, + providers: { + anthropic: { + id: 'anthropic', + apiKey: mask(settings.providers.anthropic.apiKey), + model: settings.providers.anthropic.model, + }, + openai: { + id: 'openai', + apiKey: mask(settings.providers.openai.apiKey), + organization: settings.providers.openai.organization, + model: settings.providers.openai.model, + }, + 'openai-compatible': { + id: 'openai-compatible', + endpointUrl: settings.providers['openai-compatible'].endpointUrl, + apiKey: mask(settings.providers['openai-compatible'].apiKey), + model: settings.providers['openai-compatible'].model, + label: settings.providers['openai-compatible'].label, + }, + copilot: { + id: 'copilot', + oauthToken: mask(settings.providers.copilot.oauthToken), + expiresAt: settings.providers.copilot.expiresAt, + model: settings.providers.copilot.model, + }, + paperclip: { + id: 'paperclip', + companyId: settings.providers.paperclip.companyId, + apiKey: mask(settings.providers.paperclip.apiKey), + apiBaseUrl: settings.providers.paperclip.apiBaseUrl, + model: settings.providers.paperclip.model, + }, + }, + } +} diff --git a/src/embedded/redaction.ts b/src/embedded/redaction.ts new file mode 100644 index 0000000..f4090e9 --- /dev/null +++ b/src/embedded/redaction.ts @@ -0,0 +1,219 @@ +/** + * Secret redaction for prompts and tool results before they leave the + * machine. Applied to every string that goes to a provider — system prompt, + * conversation messages, and tool results. + * + * Three rules: + * 1. Catch common provider-key and credential shapes by structure (prefix + + * high-entropy tail), not by exhaustive enumeration. New providers fall + * into one of these shapes naturally. + * 2. Never alter byte ranges that are not a secret. Redacted output is the + * same string with literal `[REDACTED: