diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 698d155819..c59e323a95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,13 @@ name: Public CI on: push: - branches: [main] + branches: [main, "fix/additive-instruction-snapshot"] pull_request: branches: [main] +permissions: + contents: read + concurrency: group: public-ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -32,10 +35,42 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Build SDK + - name: Build SDK (including declarations) + id: sdk + continue-on-error: true run: bun run build:sdk + - name: Additive instruction regression tests + run: | + FREEBUFF_MODE=true bun run cli/scripts/prebuild-agents.ts + bun test sdk/src/__tests__/append-instructions.test.ts cli/src/utils/append-instructions.test.ts + bun test cli/src/utils/append-instructions-admission.test.ts + + - name: Stock-source negative control for SDK composition + run: | + git fetch --depth=1 origin 80c2f596b5af1a331fb2904f392a8d77485eb4f2 + cp sdk/src/run.ts /tmp/patched-run.ts + git show 80c2f596b5af1a331fb2904f392a8d77485eb4f2:sdk/src/run.ts > sdk/src/run.ts + set +e + FREEBUFF_MODE=true bun run cli/scripts/prebuild-agents.ts + bun test sdk/src/__tests__/append-instructions.test.ts > /tmp/negative-control.log 2>&1 + result=$? + set -e + cp /tmp/patched-run.ts sdk/src/run.ts + cat /tmp/negative-control.log + test "$result" -ne 0 + grep -q 'HOST_RULE_CANARY' /tmp/negative-control.log + - name: Build Freebuff binary + env: + NEXT_PUBLIC_CB_ENVIRONMENT: prod + NEXT_PUBLIC_CODEBUFF_APP_URL: https://codebuff.com + NEXT_PUBLIC_SUPPORT_EMAIL: support@freebuff.com + NEXT_PUBLIC_POSTHOG_API_KEY: phc_public_placeholder + NEXT_PUBLIC_POSTHOG_HOST_URL: https://us.i.posthog.com + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: pk_test_placeholder + NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: https://billing.stripe.com/p/login/test + NEXT_PUBLIC_WEB_PORT: '3000' run: bun freebuff/cli/build.ts 0.0.0-ci - name: Smoke test Freebuff binary @@ -52,3 +87,17 @@ jobs: chmod +x cli/bin/freebuff cli/bin/freebuff --version bun cli/scripts/smoke-binary.ts cli/bin/freebuff + + - name: Upload exact-head Linux prerelease (not a release) + if: always() + uses: actions/upload-artifact@v4 + with: + name: freebuff-linux-${{ github.sha }} + path: | + cli/bin/freebuff + cli/bin/tree-sitter.wasm + retention-days: 7 + + - name: Preserve declaration-build failure + if: steps.sdk.outcome == 'failure' + run: exit 1 diff --git a/bun.lock b/bun.lock index a05565a5d3..f9cd0de9a6 100644 --- a/bun.lock +++ b/bun.lock @@ -52,6 +52,7 @@ "jimp": "^1.6.0", "node-machine-id": "^1.1.12", "open": "^10.1.0", + "picocolors": "1.1.1", "pino": "9.4.0", "react": "^19.0.0", "remark-breaks": "^4.0.0", @@ -1107,6 +1108,8 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "pino": ["pino@9.4.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^7.0.0", "process-warning": "^4.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w=="], diff --git a/cli/package.json b/cli/package.json index f873cd35aa..871ebd0b61 100644 --- a/cli/package.json +++ b/cli/package.json @@ -41,6 +41,7 @@ "jimp": "^1.6.0", "node-machine-id": "^1.1.12", "open": "^10.1.0", + "picocolors": "1.1.1", "pino": "9.4.0", "react": "^19.0.0", "remark-breaks": "^4.0.0", diff --git a/cli/scripts/build-binary.ts b/cli/scripts/build-binary.ts index 4b2ef71b7b..1a8cba383e 100644 --- a/cli/scripts/build-binary.ts +++ b/cli/scripts/build-binary.ts @@ -144,9 +144,10 @@ async function main() { env: process.env, }) - // Ensure SDK assets exist before compiling the CLI + // The binary consumes runtime SDK assets, not TypeScript declarations. + // Declaration generation remains a separate SDK CI check. log('Building SDK dependencies...') - runCommand('bun', ['run', '--cwd', '../sdk', 'build'], { + runCommand('bun', ['run', '--cwd', '../sdk', 'build', '--no-types'], { cwd: cliRoot, env: process.env, }) diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 0bcdaedb8a..a3a0156399 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -3,6 +3,7 @@ import { requestFreebuffChatAdmission, } from '../state/freebuff-chat-store' import { capturePendingAttachments } from '../utils/pending-attachments' +import { AppendInstructionsError } from '../utils/append-instructions' import { randomUUID } from 'node:crypto' import { useCallback, useEffect, useRef } from 'react' @@ -565,7 +566,9 @@ export const useSendMessage = ({ setMessages((prev) => [ ...prev, createErrorChatMessage( - shouldUseByok + error instanceof AppendInstructionsError + ? error.message + : shouldUseByok ? byokLoadFailureMessage(error) : '⚠️ Unable to create the client. Please check your authentication and try again.', ), diff --git a/cli/src/utils/append-instructions-admission.test.ts b/cli/src/utils/append-instructions-admission.test.ts new file mode 100644 index 0000000000..3237674ed6 --- /dev/null +++ b/cli/src/utils/append-instructions-admission.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'bun:test' +import { getCodebuffClient, resetCodebuffClient } from './codebuff-client' +import { AppendInstructionsError } from './append-instructions' + +test('invalid host input fails real CLI client admission before auth/provider and remains fail closed after reset', async () => { + const original = process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE + const originalFetch = globalThis.fetch + let requests = 0 + globalThis.fetch = (async () => { requests++; throw new Error('provider must not be contacted') }) as typeof fetch + try { + process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE = '/missing-private-instruction-canary' + for (let attempt = 0; attempt < 2; attempt++) { + resetCodebuffClient() + await expect(getCodebuffClient()).rejects.toBeInstanceOf(AppendInstructionsError) + delete process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE + } + expect(requests).toBe(0) + } finally { + globalThis.fetch = originalFetch + if (original === undefined) delete process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE + else process.env.FREEBUFF_APPEND_INSTRUCTIONS_FILE = original + } +}) diff --git a/cli/src/utils/append-instructions.test.ts b/cli/src/utils/append-instructions.test.ts new file mode 100644 index 0000000000..f393920360 --- /dev/null +++ b/cli/src/utils/append-instructions.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AppendInstructionsError, createAppendInstructionsSnapshot, loadAppendInstructions } from './append-instructions' + +test('unset is a no-op; process snapshot caches success and failure across client resets', async () => { + expect(await loadAppendInstructions()).toBeUndefined() + let reads = 0 + const snapshot = createAppendInstructionsSnapshot(async () => { reads++; return 'first' }) + expect(await snapshot('/first')).toBe('first') + expect(await snapshot('/changed')).toBe('first') + expect(reads).toBe(1) + const failed = createAppendInstructionsSnapshot(async () => { reads++; throw new AppendInstructionsError() }) + await expect(failed('/bad')).rejects.toBeInstanceOf(AppendInstructionsError) + await expect(failed('/fixed')).rejects.toBeInstanceOf(AppendInstructionsError) + expect(reads).toBe(2) +}) + +test('bounded regular UTF-8 input preserves Unicode; all failures redact path/content', async () => { + const dir = await mkdtemp(join(tmpdir(), 'freebuff-private-path-')) + try { + const file = join(dir, 'private-canary') + for (const content of [' 你好\n', 'a'.repeat(65536), 'é'.repeat(32768)]) { + await writeFile(file, content) + expect(await loadAppendInstructions(file)).toBe(content) + } + for (const content of ['', ' ', 'a\0b', Buffer.from([255]), 'a'.repeat(65537), 'é'.repeat(32769)]) { + await writeFile(file, content) + try { await loadAppendInstructions(file); throw new Error('accepted invalid input') } catch (error) { + expect(error).toBeInstanceOf(AppendInstructionsError) + expect(String(error)).not.toContain(dir) + expect(String(error)).not.toContain('private-canary') + expect((error as Error).cause).toBeUndefined() + } + } + await writeFile(file, 'valid') + const link = join(dir, 'symlink'); await symlink(file, link) + for (const path of [link, dir, join(dir, 'missing'), 'relative', '']) { + await expect(loadAppendInstructions(path)).rejects.toBeInstanceOf(AppendInstructionsError) + } + } finally { await rm(dir, { recursive: true, force: true }) } +}) + + +test('growth after stat remains bounded and decoder/read failures close the descriptor', async () => { + for (const scenario of ['growth', 'decode', 'read'] as const) { + let closed = 0 + const fakeOpen = (async () => ({ + stat: async () => ({ isFile: () => true, size: 1 }), + read: async (buffer: Buffer, offset: number, length: number) => { + expect(length).toBeLessThanOrEqual(65537) + if (scenario === 'read') throw new Error('private path/content must disappear') + if (offset) return { bytesRead: 0 } + buffer.fill(scenario === 'decode' ? 255 : 65) + return { bytesRead: scenario === 'growth' ? 65537 : 1 } + }, + close: async () => { closed++ }, + })) as unknown as Parameters[1] + await expect(loadAppendInstructions('/private-canary', fakeOpen)).rejects.toBeInstanceOf(AppendInstructionsError) + expect(closed).toBe(1) + } +}) diff --git a/cli/src/utils/append-instructions.ts b/cli/src/utils/append-instructions.ts new file mode 100644 index 0000000000..ae325145f5 --- /dev/null +++ b/cli/src/utils/append-instructions.ts @@ -0,0 +1,47 @@ +import { constants } from 'node:fs' +import { open } from 'node:fs/promises' +import { isAbsolute } from 'node:path' +import { AppendInstructionsError, MAX_APPEND_INSTRUCTIONS_BYTES, validateAppendInstructions } from '@codebuff/common/append-instructions' + +export { AppendInstructionsError } + +/** POSIX file input. Never retain filesystem errors, paths or contents in errors. */ +export async function loadAppendInstructions(path?: string, openFile = open): Promise { + if (path === undefined) return undefined + try { + if (!['linux', 'darwin'].includes(process.platform) || + !isAbsolute(path) || /[\0\r\n]/.test(path) || + !constants.O_NOFOLLOW || !constants.O_NONBLOCK) { + throw new AppendInstructionsError() + } + const file = await openFile(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + try { + const stat = await file.stat() + if (!stat.isFile() || stat.size === 0 || stat.size > MAX_APPEND_INSTRUCTIONS_BYTES) { + throw new AppendInstructionsError() + } + const bytes = Buffer.alloc(MAX_APPEND_INSTRUCTIONS_BYTES + 1) + let length = 0 + while (length < bytes.length) { + const { bytesRead } = await file.read(bytes, length, bytes.length - length, null) + if (!bytesRead) break + length += bytesRead + } + if (length > MAX_APPEND_INSTRUCTIONS_BYTES) throw new AppendInstructionsError() + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length)) + validateAppendInstructions(text) + return text + } finally { + await file.close() + } + } catch { + throw new AppendInstructionsError() + } +} + +/** Cache success AND failure for process lifetime, independent of auth/client resets. */ +export function createAppendInstructionsSnapshot(loader = loadAppendInstructions) { + let snapshot: Promise | undefined + return (path?: string) => snapshot ??= loader(path) +} +export const getProcessAppendInstructions = createAppendInstructionsSnapshot() diff --git a/cli/src/utils/codebuff-client.ts b/cli/src/utils/codebuff-client.ts index 236bfebbf3..8e4b780507 100644 --- a/cli/src/utils/codebuff-client.ts +++ b/cli/src/utils/codebuff-client.ts @@ -1,3 +1,4 @@ +import { getProcessAppendInstructions } from './append-instructions' import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' import { AskUserBridge } from '@codebuff/common/utils/ask-user-bridge' import { CodebuffClient } from '@codebuff/sdk' @@ -53,6 +54,10 @@ export function resetCodebuffClient(): void { export async function getCodebuffClient( options: { byok?: ResolvedByokConnection } = {}, ): Promise { + // Validate before auth/client admission, even when a client is already cached. + const appendInstructions = await getProcessAppendInstructions( + getSystemProcessEnv().FREEBUFF_APPEND_INSTRUCTIONS_FILE, + ) const byokIdentity = options.byok ? `${options.byok.id}:${options.byok.revision}` : null @@ -96,6 +101,7 @@ export async function getCodebuffClient( } : {}), cwd: projectRoot, + ...(appendInstructions !== undefined ? { appendInstructions } : {}), // Keeps the model's skill list identical to the one the registry shows // (utils/skill-registry.ts). The SDK default is project-only so that a // server embedding it cannot read a home directory by omission; the diff --git a/common/src/append-instructions.ts b/common/src/append-instructions.ts new file mode 100644 index 0000000000..188de2b59f --- /dev/null +++ b/common/src/append-instructions.ts @@ -0,0 +1,20 @@ +/** Creation-time host context; retained in ordinary native conversation history. */ +export const APPEND_INSTRUCTIONS_KEY = 'host://append-instructions' +export const MAX_APPEND_INSTRUCTIONS_BYTES = 64 * 1024 + +export class AppendInstructionsError extends Error { + constructor(message = 'Invalid append instructions configuration; use a readable UTF-8 regular file of 1–65536 bytes.') { + super(message) + this.name = 'AppendInstructionsError' + } +} + +export function validateAppendInstructions(value: unknown): asserts value is string { + if (typeof value !== 'string' || value.length > MAX_APPEND_INSTRUCTIONS_BYTES || + !value.trim() || value.includes('\0') || + new TextEncoder().encode(value).length > MAX_APPEND_INSTRUCTIONS_BYTES || + // Reject unpaired UTF-16 surrogates rather than silently replacing input. + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { globalThis.fetch = originalFetch; for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }) }) + +// Native bundled system prompt, not a test-only knowledge placeholder. +const agent = { ...createBase3(), id: 'snapshot-native-root', toolNames: [], spawnableAgents: [] } as AgentDefinition +const byok = { + id: 'snapshot', revision: 1, name: 'test', provider: 'openai-compatible' as const, + baseUrl: 'http://127.0.0.1:9876/v1', model: 'scripted/model', apiKey: 'test-only', + credentialRef: 'connection:snapshot:1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +} + +test('constructor snapshot reaches native provider prompt; persists across turns and compact without replacing rules', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'freebuff-snapshot-')); dirs.push(cwd) + await writeFile(join(cwd, 'AGENTS.md'), 'PROJECT_RULE_CANARY') + const requests: any[] = [] + globalThis.fetch = (async (_input, init) => { + const body = JSON.parse(String(init?.body)); requests.push(body) + return new Response(`data: ${JSON.stringify({ id: 'done', object: 'chat.completion.chunk', created: 1, model: body.model, + choices: [{ index: 0, delta: { content: 'Complete.' }, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`, { headers: { 'content-type': 'text/event-stream' } }) + }) as typeof fetch + const snapshot = 'HOST_RULE_CANARY 你好' + const client = new CodebuffClient({ cwd, byok, appendInstructions: snapshot, agentDefinitions: [agent] }) + const first = await client.run({ agent: agent.id, prompt: '-Create hello.txt.' }) + expect(first.output.type).not.toBe('error') + const system = JSON.stringify(requests[0].messages.filter((m: any) => m.role === 'system')) + expect(system).toContain('You are Buffy') + expect(system).toContain('PROJECT_RULE_CANARY') + expect(system).toContain(snapshot) + const user = JSON.stringify(requests[0].messages.filter((m: any) => m.role === 'user')) + expect(user).toContain('-Create hello.txt.') + expect(user).not.toContain(snapshot) + expect(first.sessionState!.fileContext.userKnowledgeFiles![APPEND_INSTRUCTIONS_KEY]).toBe(snapshot) + // Preserve native home-map data without changing real developer home files. + first.sessionState!.fileContext.userKnowledgeFiles!['~/.AGENTS.md'] = 'USER_RULE_CANARY' + const saved = JSON.parse(JSON.stringify(first)) + const plainClient = new CodebuffClient({ cwd, byok, agentDefinitions: [agent] }) + const second = await plainClient.run({ agent: agent.id, prompt: 'Continue.', previousRun: saved }) + expect(second.output.type).not.toBe('error') + const secondSystem = JSON.stringify(requests.at(-1).messages.filter((m: any) => m.role === 'system')) + expect(secondSystem).toContain('USER_RULE_CANARY') + expect(secondSystem.split(snapshot)).toHaveLength(2) + const compacted = await client.run({ agent: agent.id, prompt: '/compact', previousRun: second }) + expect(compacted.output.type).not.toBe('error') + expect(compacted.sessionState!.fileContext.userKnowledgeFiles![APPEND_INSTRUCTIONS_KEY]).toBe(snapshot) + const afterCompact = await client.run({ agent: agent.id, prompt: 'After compact.', previousRun: compacted }) + expect(afterCompact.output.type).not.toBe('error') + const compactSystem = JSON.stringify(requests.at(-1).messages.filter((m: any) => m.role === 'system')) + expect(compactSystem.split(snapshot)).toHaveLength(2) + const before = requests.length + for (const input of ['changed', '', '\0', 'a'.repeat(65537), '\ud800', null, 42]) { + await expect(plainClient.run({ agent: agent.id, prompt: 'Continue.', previousRun: compacted, appendInstructions: input as string })).rejects.toBeInstanceOf(AppendInstructionsError) + } + expect(requests).toHaveLength(before) + const legacy = JSON.parse(JSON.stringify(first)); delete legacy.sessionState.fileContext.userKnowledgeFiles + await expect(client.run({ agent: agent.id, prompt: 'Retrofit.', previousRun: legacy })).rejects.toThrow('start a new conversation') + const defaultRun = await plainClient.run({ agent: agent.id, prompt: 'Legacy.', previousRun: legacy }) + expect(defaultRun.output.type).not.toBe('error') + expect(defaultRun.sessionState!.fileContext.userKnowledgeFiles?.[APPEND_INSTRUCTIONS_KEY]).toBeUndefined() +}) diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 5440ba71b4..ae3e0b2630 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -67,6 +67,8 @@ export class CodebuffClient { // An omitted/undefined per-call config inherits the client snapshot. // run() enforces the history pin unless the host explicitly authorizes a switch. byok: options.byok ?? this.options.byok, + appendInstructions: options.appendInstructions === undefined + ? this.options.appendInstructions : options.appendInstructions, }) } diff --git a/sdk/src/run.ts b/sdk/src/run.ts index ede227775a..32b74928be 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -1,3 +1,4 @@ +import { APPEND_INSTRUCTIONS_KEY, AppendInstructionsError, validateAppendInstructions } from '@codebuff/common/append-instructions' import path from 'path' import { callMainPrompt } from '@codebuff/agent-runtime/main-prompt' @@ -202,6 +203,8 @@ export type CodebuffClientOptions = { * `computeProjectIndexFromFiles`). Skips the per-run tree-sitter parse; * ignored when `projectFiles` is absent. */ projectIndex?: ComputedProjectIndex + /** Immutable creation snapshot, rendered by native knowledge-aware templates. */ + appendInstructions?: string knowledgeFiles?: Record agentDefinitions?: AgentDefinition[] maxAgentSteps?: number @@ -462,6 +465,15 @@ type RunExecutionOptions = RunOptions & type RunReturnType = RunState export async function run(options: RunExecutionOptions): Promise { + if (options.appendInstructions !== undefined) { + validateAppendInstructions(options.appendInstructions) + if (options.previousRun?.sessionState && + options.previousRun.sessionState.fileContext.userKnowledgeFiles?.[APPEND_INSTRUCTIONS_KEY] !== options.appendInstructions) { + throw new AppendInstructionsError( + 'Append instructions are fixed when a conversation starts; start a new conversation.', + ) + } + } const requestedInference = options.byok ? { source: 'byok' as const, connectionId: options.byok.id, revision: options.byok.revision, model: options.byok.model } : { source: 'codebuff' as const } @@ -544,6 +556,7 @@ async function runOnce({ projectFiles, projectIndex, knowledgeFiles, + appendInstructions, agentDefinitions, maxAgentSteps = MAX_AGENT_STEPS_DEFAULT, env, @@ -639,6 +652,12 @@ async function runOnce({ logger, }) } + if (!previousRun?.sessionState && appendInstructions !== undefined) { + sessionState.fileContext.userKnowledgeFiles = { + ...sessionState.fileContext.userKnowledgeFiles, + [APPEND_INSTRUCTIONS_KEY]: appendInstructions, + } + } const traceSessionId = previousRun?.traceSessionId ?? traceSessionIdOption ?? crypto.randomUUID() applyTerminalShell(sessionState, terminalShell)